react-native-image-stitcher 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/README.md +33 -17
  3. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +33 -5
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +73 -1
  5. package/dist/camera/Camera.d.ts +226 -0
  6. package/dist/camera/Camera.js +208 -20
  7. package/dist/camera/CameraView.d.ts +6 -0
  8. package/dist/camera/CameraView.js +2 -2
  9. package/dist/camera/CaptureHeader.js +39 -16
  10. package/dist/camera/CapturePreview.js +13 -1
  11. package/dist/camera/CaptureThumbnailStrip.d.ts +25 -1
  12. package/dist/camera/CaptureThumbnailStrip.js +17 -4
  13. package/dist/camera/PanoramaBandOverlay.d.ts +76 -0
  14. package/dist/camera/PanoramaBandOverlay.js +90 -33
  15. package/dist/camera/PanoramaConfirmModal.js +11 -1
  16. package/dist/camera/selectCaptureDevice.d.ts +93 -0
  17. package/dist/camera/selectCaptureDevice.js +131 -0
  18. package/dist/camera/useCapture.d.ts +40 -0
  19. package/dist/camera/useCapture.js +50 -12
  20. package/dist/camera/useContentRotation.d.ts +99 -0
  21. package/dist/camera/useContentRotation.js +124 -0
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +6 -5
  24. package/package.json +1 -1
  25. package/src/camera/Camera.tsx +546 -32
  26. package/src/camera/CameraView.tsx +9 -0
  27. package/src/camera/CaptureHeader.tsx +39 -16
  28. package/src/camera/CapturePreview.tsx +12 -0
  29. package/src/camera/CaptureThumbnailStrip.tsx +44 -4
  30. package/src/camera/PanoramaBandOverlay.tsx +97 -35
  31. package/src/camera/PanoramaConfirmModal.tsx +10 -0
  32. package/src/camera/__tests__/bandThumbRotation.test.ts +120 -0
  33. package/src/camera/__tests__/homeIndicatorEdge.test.ts +116 -0
  34. package/src/camera/__tests__/selectCaptureDevice.test.ts +177 -0
  35. package/src/camera/__tests__/useContentRotation.test.ts +89 -0
  36. package/src/camera/selectCaptureDevice.ts +187 -0
  37. package/src/camera/useCapture.ts +99 -11
  38. package/src/camera/useContentRotation.ts +149 -0
  39. package/src/index.ts +6 -2
@@ -41,7 +41,22 @@
41
41
  import React from 'react';
42
42
  import { type StyleProp, type ViewStyle } from 'react-native';
43
43
  import type { DrawableFrameProcessor, ReadonlyFrameProcessor } from 'react-native-vision-camera';
44
+ import { type CaptureHeaderProps } from './CaptureHeader';
45
+ import { type CapturePreviewAction } from './CapturePreview';
46
+ import { type CaptureThumbnailItem } from './CaptureThumbnailStrip';
47
+ import { type DeviceOrientation } from './useDeviceOrientation';
44
48
  export type CaptureSource = 'ar' | 'non-ar';
49
+ /**
50
+ * v0.13.2 — which capture sources the host ALLOWS. A constraint on top
51
+ * of `defaultCaptureSource` (which picks the initial source within this
52
+ * constraint):
53
+ * 'both' — AR and non-AR both available; AR toggle is shown.
54
+ * 'ar' — AR only; AR toggle hidden (nothing to switch to), and the
55
+ * 0.5× lens chooser is hidden (ARKit/ARCore don't expose the
56
+ * ultra-wide).
57
+ * 'non-ar' — non-AR only; AR toggle hidden.
58
+ */
59
+ export type CaptureSourcesMode = 'ar' | 'non-ar' | 'both';
45
60
  export type CameraLens = '1x' | '0.5x';
46
61
  export type StitchMode = 'auto' | 'panorama' | 'scans';
47
62
  export type Blender = 'multiband' | 'feather';
@@ -139,6 +154,19 @@ export interface CameraProps {
139
154
  enablePhotoMode?: boolean;
140
155
  enablePanoramaMode?: boolean;
141
156
  showSettingsButton?: boolean;
157
+ /**
158
+ * v0.13.2 — which capture sources the host allows (default `'both'`).
159
+ * Constrains both the runtime AR toggle and `defaultCaptureSource`:
160
+ * - `'both'` : AR + non-AR; the AR toggle is shown so the user can
161
+ * switch at runtime.
162
+ * - `'ar'` : AR only. AR toggle hidden (nothing to toggle); the
163
+ * 0.5× lens chooser is also hidden (ARKit/ARCore can't use the
164
+ * ultra-wide), so the camera stays on the AR-capable 1× lens.
165
+ * - `'non-ar'`: non-AR only. AR toggle hidden.
166
+ * When set to a single source, that source wins regardless of
167
+ * `defaultCaptureSource`.
168
+ */
169
+ captureSources?: CaptureSourcesMode;
142
170
  style?: StyleProp<ViewStyle>;
143
171
  /**
144
172
  * Which incremental stitcher engine to drive. Default
@@ -211,6 +239,158 @@ export interface CameraProps {
211
239
  * for an abandoned capture.
212
240
  */
213
241
  onCaptureAbandoned?: (reason: 'orientation-drift') => void;
242
+ /**
243
+ * v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
244
+ *
245
+ * - **Uncontrolled** (omit `flash`): `<Camera>` owns the flash
246
+ * state internally. Tapping the built-in flash button toggles
247
+ * it on/off. `onFlashChange` (if supplied) fires for telemetry.
248
+ * - **Controlled** (supply `flash`): the parent owns the state.
249
+ * The built-in button still renders and fires `onFlashChange`
250
+ * on press, but it's a no-op unless the parent updates `flash`
251
+ * in response.
252
+ *
253
+ * Both shapes coexist with the v0.13 "flash button is on by default"
254
+ * built-in (see the bottom-left bar slot in the JSX). Hosts that
255
+ * want their own flash chrome can opt out via `showFlashButton={false}`
256
+ * and drive the underlying torch by controlling `flash` directly.
257
+ *
258
+ * ## AR-mode behaviour
259
+ *
260
+ * In AR mode (`defaultCaptureSource="ar"` or runtime-toggled),
261
+ * ARKit / ARCore own the `AVCaptureDevice` and don't expose the
262
+ * torch through vision-camera's pipeline. The built-in flash
263
+ * button renders as visibly disabled (a11y label "Flash unavailable
264
+ * in AR mode") and `flash` is forced to `'off'` regardless of
265
+ * controlled/uncontrolled state. Hosts that need flash should
266
+ * toggle to non-AR before enabling.
267
+ */
268
+ flash?: 'on' | 'off';
269
+ /**
270
+ * v0.13.0 — fires when the user taps the built-in flash button.
271
+ * In uncontrolled mode, the internal state has already flipped
272
+ * (single render delay). In controlled mode, the parent must
273
+ * update the `flash` prop in response or the visual toggle is
274
+ * a no-op. Useful in either mode for telemetry.
275
+ */
276
+ onFlashChange?: (next: 'on' | 'off') => void;
277
+ /**
278
+ * v0.13.0 — show the built-in flash button in the bottom-left
279
+ * slot. Defaults to `true`. Hosts that render their own flash
280
+ * chrome (and drive the underlying torch via the controlled
281
+ * `flash` prop) can opt out by setting this to `false`.
282
+ */
283
+ showFlashButton?: boolean;
284
+ /**
285
+ * v0.13.0 — built-in CaptureHeader title. When set, `<Camera>`
286
+ * renders a top-of-screen header showing this title (centred)
287
+ * with an optional back affordance + guidance subtitle + the
288
+ * existing settings gear absorbed into the header's right side.
289
+ *
290
+ * When `headerTitle` is undefined the header is not rendered
291
+ * (matches pre-v0.13 behaviour: top of preview is bare except
292
+ * for the standalone settings gear gated on `showSettingsButton`).
293
+ *
294
+ * Combine with `onHeaderBack`, `headerBackLabel`, `headerGuidance`,
295
+ * and `headerColors` to customise the rest of the header. Hosts
296
+ * that need richer header chrome can omit `headerTitle` and
297
+ * compose their own `<CaptureHeader>` above `<Camera>`.
298
+ */
299
+ headerTitle?: string;
300
+ /**
301
+ * v0.13.0 — header back-button callback. When supplied (and
302
+ * `headerTitle` is set), the header renders a back affordance
303
+ * on the left. Omitted ⇒ no back button (the title stays
304
+ * centred).
305
+ */
306
+ onHeaderBack?: () => void;
307
+ /**
308
+ * v0.13.0 — header back-button label. Defaults to "‹ Back".
309
+ * No effect unless `headerTitle` and `onHeaderBack` are both set.
310
+ */
311
+ headerBackLabel?: string;
312
+ /**
313
+ * v0.13.0 — optional second-line subtitle shown below the
314
+ * header title. E.g. "Photograph the promotional cola end cap."
315
+ * Renders nothing when undefined. No effect unless `headerTitle`
316
+ * is set.
317
+ */
318
+ headerGuidance?: string;
319
+ /**
320
+ * v0.13.0 — colour overrides for the built-in header. Defaults
321
+ * are white-on-black to stay legible over the camera preview.
322
+ * No effect unless `headerTitle` is set.
323
+ */
324
+ headerColors?: CaptureHeaderProps['colors'];
325
+ /**
326
+ * v0.13.0 — when provided (even as `[]`), `<Camera>` renders a
327
+ * built-in `CaptureThumbnailStrip` above the bottom controls
328
+ * showing the host's capture history. Each item is a plain
329
+ * `{ id, uri, width?, height? }` object; the strip handles
330
+ * aspect-ratio rendering, tap-to-preview, and the count line.
331
+ *
332
+ * Omit (`undefined`) to skip the strip entirely. Hosts using
333
+ * the strip independently (e.g. on a non-camera screen) can keep
334
+ * importing `CaptureThumbnailStrip` directly from the library —
335
+ * the prop here is the convenience wiring for in-`<Camera>` use.
336
+ *
337
+ * Captures emitted by `<Camera>`'s `onCapture` are NOT added to
338
+ * this array automatically — the host owns the canonical list
339
+ * (typically persisted to its own DB) and updates the prop in
340
+ * response. This matches the SDK's "Camera owns runtime state,
341
+ * host persists" pattern.
342
+ */
343
+ thumbnails?: CaptureThumbnailItem[];
344
+ /**
345
+ * v0.13.0 — minimum-photos hint for the count line. Renders
346
+ * "n / minPhotos min" with the success colour when reached,
347
+ * warning colour otherwise.
348
+ */
349
+ thumbnailsMin?: number;
350
+ /**
351
+ * v0.13.0 — maximum-photos hint for the count line. Renders
352
+ * "· maxPhotos max" suffix. No enforcement — the host decides
353
+ * what to do at the cap.
354
+ */
355
+ thumbnailsMax?: number;
356
+ /**
357
+ * v0.13.0 — tap handler for thumbnails. When set, replaces the
358
+ * strip's built-in tap-to-preview modal; the host shows its own
359
+ * preview UI (e.g. with delete / recapture buttons gated on
360
+ * sync state). Omit to use the built-in preview.
361
+ */
362
+ onThumbnailPress?: (item: CaptureThumbnailItem) => void;
363
+ /**
364
+ * v0.13.0 — when set, `<Camera>` renders a built-in `CapturePreview`
365
+ * modal as `visible`. Use this for post-stitch confirmation:
366
+ * after `onCapture` emits, the host stores the result and sets
367
+ * `capturePreview` to the new image, with `capturePreviewActions`
368
+ * = `[Discard, Save]` (or similar). Setting `undefined` hides
369
+ * the modal.
370
+ *
371
+ * Hosts using the modal for thumbnail tap-to-preview can leave
372
+ * this undefined and let the built-in strip's preview handle
373
+ * that case.
374
+ */
375
+ capturePreview?: {
376
+ imageUri: string;
377
+ imageWidth?: number;
378
+ imageHeight?: number;
379
+ title?: string;
380
+ };
381
+ /**
382
+ * v0.13.0 — action buttons rendered along the bottom of the
383
+ * `CapturePreview` modal. Empty array (or undefined) renders
384
+ * no buttons, only the close affordance.
385
+ */
386
+ capturePreviewActions?: CapturePreviewAction[];
387
+ /**
388
+ * v0.13.0 — fires when the user dismisses the `capturePreview`
389
+ * modal (tap close, backdrop tap, hardware back on Android).
390
+ * The host is expected to clear the `capturePreview` prop in
391
+ * response.
392
+ */
393
+ onCapturePreviewClose?: () => void;
214
394
  /**
215
395
  * Optional host-supplied vision-camera frame processor.
216
396
  *
@@ -305,4 +485,50 @@ export interface CameraProps {
305
485
  * The public `<Camera>` component.
306
486
  */
307
487
  export declare function Camera(props: CameraProps): React.JSX.Element;
488
+ /**
489
+ * v0.12.0 — JS edge corresponding to the physical home-indicator
490
+ * side of the device. This is where the shutter + controls anchor
491
+ * to so they're always within thumb reach of the user's grip
492
+ * (matching iOS Camera's behaviour).
493
+ *
494
+ * Combines two signals:
495
+ * - `jsLandscape`: whether the OS rotated the framebuffer. True
496
+ * only for non-locked hosts in device-landscape.
497
+ * - `deviceOrient`: physical device orientation from the sensor.
498
+ *
499
+ * Truth table:
500
+ * | jsLandscape | deviceOrient | edge |
501
+ * |--- |--- |--- |
502
+ * | false | any | bottom | (portrait JS coords —
503
+ * | | | | device-bottom = JS-bottom
504
+ * | | | | in both locked and
505
+ * | | | | non-locked-portrait)
506
+ * | true | landscape-left | right | (screen rotated, home
507
+ * | | | | indicator on user-right)
508
+ * | true | landscape-right | left | (mirror)
509
+ *
510
+ * Caveats:
511
+ * - Non-locked + upside-down doesn't surface JS-top here because
512
+ * upside-down doesn't change window dimensions; we can't
513
+ * distinguish locked-portrait-with-device-flipped from
514
+ * non-locked-portrait-with-screen-flipped-180°. Defaults to
515
+ * JS-bottom which matches the more common locked case. Add
516
+ * handling here when a host needs upside-down support.
517
+ * - jsLandscape=true with non-landscape device shouldn't happen
518
+ * in steady state — only during a transition mid-rotation.
519
+ * Falls through to 'right' as a defensive default.
520
+ */
521
+ type HomeIndicatorEdge = 'bottom' | 'top' | 'left' | 'right';
522
+ declare function homeIndicatorEdge(jsLandscape: boolean, deviceOrient: DeviceOrientation): HomeIndicatorEdge;
523
+ /**
524
+ * v0.12.0 — true when the anchor edge is on a side (left/right), so
525
+ * the band + shutter row need to be vertical strips. Top/bottom
526
+ * anchors yield horizontal strips.
527
+ */
528
+ declare function isSideEdge(edge: HomeIndicatorEdge): boolean;
529
+ /** @internal test-only — see `homeIndicatorEdge`. */
530
+ export declare const _homeIndicatorEdgeForTests: typeof homeIndicatorEdge;
531
+ /** @internal test-only — see `isSideEdge`. */
532
+ export declare const _isSideEdgeForTests: typeof isSideEdge;
533
+ export {};
308
534
  //# sourceMappingURL=Camera.d.ts.map
@@ -74,7 +74,7 @@ var __importStar = (this && this.__importStar) || (function () {
74
74
  };
75
75
  })();
76
76
  Object.defineProperty(exports, "__esModule", { value: true });
77
- exports.CameraError = void 0;
77
+ exports._isSideEdgeForTests = exports._homeIndicatorEdgeForTests = exports.CameraError = void 0;
78
78
  exports.Camera = Camera;
79
79
  const react_1 = __importStar(require("react"));
80
80
  const react_native_1 = require("react-native");
@@ -83,6 +83,9 @@ const useARSession_1 = require("../ar/useARSession");
83
83
  const ARCameraView_1 = require("./ARCameraView");
84
84
  const CameraShutter_1 = require("./CameraShutter");
85
85
  const CameraView_1 = require("./CameraView");
86
+ const CaptureHeader_1 = require("./CaptureHeader");
87
+ const CapturePreview_1 = require("./CapturePreview");
88
+ const CaptureThumbnailStrip_1 = require("./CaptureThumbnailStrip");
86
89
  const CaptureStatusOverlay_1 = require("./CaptureStatusOverlay");
87
90
  const CaptureDebugOverlay_1 = require("./CaptureDebugOverlay");
88
91
  const CaptureMemoryPill_1 = require("./CaptureMemoryPill");
@@ -96,6 +99,7 @@ const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings")
96
99
  const lowMemDevice_1 = require("./lowMemDevice");
97
100
  const useCapture_1 = require("./useCapture");
98
101
  const useDeviceOrientation_1 = require("./useDeviceOrientation");
102
+ const useContentRotation_1 = require("./useContentRotation");
99
103
  const useOrientationDrift_1 = require("./useOrientationDrift");
100
104
  const OrientationDriftModal_1 = require("./OrientationDriftModal");
101
105
  const incremental_1 = require("../stitching/incremental");
@@ -113,10 +117,10 @@ class CameraError extends Error {
113
117
  }
114
118
  }
115
119
  exports.CameraError = CameraError;
116
- function LensChip({ lens, onChange, has0_5x }) {
120
+ function LensChip({ lens, onChange, has0_5x, contentRotation }) {
117
121
  if (!has0_5x) {
118
122
  return (react_1.default.createElement(react_native_1.View, { style: [lensChipStyles.container, lensChipStyles.singleLens] },
119
- react_1.default.createElement(react_native_1.Text, { style: lensChipStyles.label }, "1\u00D7")));
123
+ react_1.default.createElement(react_native_1.Text, { style: [lensChipStyles.label, contentRotation] }, "1\u00D7")));
120
124
  }
121
125
  return (react_1.default.createElement(react_native_1.View, { style: lensChipStyles.container },
122
126
  react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange('0.5x'), accessibilityRole: "button", accessibilityLabel: "0.5x ultra-wide lens", accessibilityState: { selected: lens === '0.5x' }, style: [
@@ -126,6 +130,7 @@ function LensChip({ lens, onChange, has0_5x }) {
126
130
  react_1.default.createElement(react_native_1.Text, { style: [
127
131
  lensChipStyles.label,
128
132
  lens === '0.5x' && lensChipStyles.labelActive,
133
+ contentRotation,
129
134
  ] }, "0.5\u00D7")),
130
135
  react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange('1x'), accessibilityRole: "button", accessibilityLabel: "1x wide-angle lens", accessibilityState: { selected: lens === '1x' }, style: [
131
136
  lensChipStyles.pill,
@@ -134,6 +139,7 @@ function LensChip({ lens, onChange, has0_5x }) {
134
139
  react_1.default.createElement(react_native_1.Text, { style: [
135
140
  lensChipStyles.label,
136
141
  lens === '1x' && lensChipStyles.labelActive,
142
+ contentRotation,
137
143
  ] }, "1\u00D7"))));
138
144
  }
139
145
  const lensChipStyles = react_native_1.StyleSheet.create({
@@ -166,11 +172,12 @@ const lensChipStyles = react_native_1.StyleSheet.create({
166
172
  color: '#1a1a1a',
167
173
  },
168
174
  });
169
- function ARToggle({ arEnabled, onToggle }) {
175
+ function ARToggle({ arEnabled, onToggle, contentRotation }) {
170
176
  return (react_1.default.createElement(react_native_1.Pressable, { onPress: onToggle, accessibilityRole: "switch", accessibilityLabel: `AR mode ${arEnabled ? 'on' : 'off'}`, accessibilityState: { checked: arEnabled }, style: [arToggleStyles.container, arEnabled && arToggleStyles.containerOn] },
171
177
  react_1.default.createElement(react_native_1.Text, { style: [
172
178
  arToggleStyles.label,
173
179
  arEnabled && arToggleStyles.labelOn,
180
+ contentRotation,
174
181
  ] }, "AR")));
175
182
  }
176
183
  const arToggleStyles = react_native_1.StyleSheet.create({
@@ -272,7 +279,14 @@ function extractPanoramaOverrides(props) {
272
279
  * The public `<Camera>` component.
273
280
  */
274
281
  function Camera(props) {
275
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
282
+ const { defaultCaptureSource = 'ar', defaultLens = '1x', captureSources = 'both', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, 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, engine = 'batch-keyframe', } = props;
283
+ // v0.13.2 — capture-source constraint (default 'both'). Derives which
284
+ // sources are permitted; `captureSources` overrides any conflicting
285
+ // `defaultCaptureSource`. Used to constrain the initial AR preference
286
+ // and to hide the AR toggle / lens chooser below.
287
+ const arAllowed = captureSources !== 'non-ar';
288
+ const nonArAllowed = captureSources !== 'ar';
289
+ const arOnly = captureSources === 'ar';
276
290
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
277
291
  // v0.12.0 — JS-layout orientation independent of device-physical.
278
292
  // `useWindowDimensions().width > height` tells us if the OS
@@ -283,8 +297,19 @@ function Camera(props) {
283
297
  const jsWindow = (0, react_native_1.useWindowDimensions)();
284
298
  const jsLandscape = jsWindow.width > jsWindow.height;
285
299
  // ── State ───────────────────────────────────────────────────────
286
- const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
287
- const [lens, setLens] = (0, react_1.useState)(defaultLens);
300
+ // v0.13.2 initial AR preference honours `defaultCaptureSource` but
301
+ // is clamped to the `captureSources` constraint: 'ar' forces on,
302
+ // 'non-ar' forces off, 'both' uses the default.
303
+ const [arPreference, setArPreference] = (0, react_1.useState)(!arAllowed ? false : !nonArAllowed ? true : defaultCaptureSource === 'ar');
304
+ // v0.13.2 — `arOnly` forces the 1× lens (the ultra-wide isn't usable
305
+ // in AR), and the lens chooser is hidden in that mode.
306
+ const [lens, setLens] = (0, react_1.useState)(arOnly ? '1x' : defaultLens);
307
+ // v0.13.0 — flash state. Controlled by `controlledFlash` when the
308
+ // host supplies the `flash` prop; otherwise owned internally and
309
+ // toggled by the built-in flash button. `effectiveFlash` below
310
+ // also forces 'off' in AR mode (ARKit / ARCore own the device's
311
+ // torch and don't surface it through vision-camera's pipeline).
312
+ const [internalFlash, setInternalFlash] = (0, react_1.useState)('off');
288
313
  const [settings, setSettings] = (0, react_1.useState)(() => (0, buildPanoramaInitialSettings_1.buildPanoramaInitialSettings)(extractPanoramaOverrides(props), (0, lowMemDevice_1.isLowMemDevice)()));
289
314
  const [settingsModalVisible, setSettingsModalVisible] = (0, react_1.useState)(false);
290
315
  const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
@@ -308,6 +333,14 @@ function Camera(props) {
308
333
  const isAR = effectiveCaptureSource === 'ar';
309
334
  const isNonAR = !isAR;
310
335
  const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
336
+ // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
337
+ // pill, flash icon, thumbnails) so their labels read upright relative
338
+ // to gravity when the device is held landscape under a PORTRAIT-LOCKED
339
+ // host (the recommended config — the JS framebuffer stays portrait, so
340
+ // without this the labels render at 90°). Returns `{}` (no-op) in the
341
+ // common upright cases, including non-locked hosts where the OS already
342
+ // rotated the framebuffer. See `useContentRotation` truth table.
343
+ const contentRotation = (0, useContentRotation_1.useContentRotation)();
311
344
  // ── Camera handoff gate ─────────────────────────────────────────
312
345
  //
313
346
  // The placeholder rendered while the underlying camera identity
@@ -336,6 +369,33 @@ function Camera(props) {
336
369
  const inFlightTransition = settledIsARRef.current !== isAR
337
370
  || settledLensRef.current !== lens
338
371
  || cameraTransitioning;
372
+ // ── v0.13.1 — Android portrait lock ─────────────────────────────
373
+ //
374
+ // Android lets a mounted view force its host Activity's orientation,
375
+ // so `<Camera>` guarantees a portrait capture surface regardless of
376
+ // the host app's manifest (even a landscape/unlocked host gets a
377
+ // portrait camera while `<Camera>` is mounted). The lock lives on
378
+ // the Activity via the native `RNSARSession` module, so it covers
379
+ // BOTH the AR (ARCore) and non-AR (vision-camera) capture paths.
380
+ //
381
+ // iOS is intentionally NOT locked here: iOS supported orientations
382
+ // are a static Info.plist declaration the host owns, and we want iOS
383
+ // hosts to be able to support landscape/unlocked capture. Hosts that
384
+ // want a portrait-only iOS app set UISupportedInterfaceOrientations
385
+ // themselves.
386
+ //
387
+ // Empty dep array — lock on mount, restore the host's PRIOR
388
+ // orientation on unmount (the native side captures it).
389
+ (0, react_1.useEffect)(() => {
390
+ if (react_native_1.Platform.OS !== 'android')
391
+ return undefined;
392
+ const arModule = react_native_1.NativeModules
393
+ .RNSARSession;
394
+ arModule?.lockPortrait?.();
395
+ return () => {
396
+ arModule?.unlockOrientation?.();
397
+ };
398
+ }, []);
339
399
  // ── Notify parent of capture-source changes ─────────────────────
340
400
  const lastEmittedSourceRef = (0, react_1.useRef)(null);
341
401
  (0, react_1.useEffect)(() => {
@@ -344,19 +404,22 @@ function Camera(props) {
344
404
  onCaptureSourceChange?.(effectiveCaptureSource);
345
405
  }
346
406
  }, [effectiveCaptureSource, onCaptureSourceChange]);
347
- // ── Lens chip availability ──────────────────────────────────────
348
- // TODO follow-up: probe the device's available physical lenses via
349
- // vision-camera's `useCameraDevices` and surface in
350
- // `useCapture().availablePhysicalDevices`. For now we assume the
351
- // 0.5x ultra-wide exists on modern devices. When it doesn't, the
352
- // lens chip degenerates to a static 1× indicator (see LensChip).
353
- const has0_5x = true;
354
407
  // ── Capture hooks ───────────────────────────────────────────────
408
+ // v0.13.2 — pass the active `lens` so useCapture uses capability-aware
409
+ // selection (multi-cam zoom-switch where available, standalone-ultra-
410
+ // wide swap otherwise). Replaces the old per-lens
411
+ // `preferredPhysicalDevice` request that mis-selected on some phones.
355
412
  const capture = (0, useCapture_1.useCapture)({
356
413
  cameraPosition: 'back',
357
414
  enableQualityChecks: false,
358
- preferredPhysicalDevice: lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
415
+ lens,
359
416
  });
417
+ // ── Lens chip availability ──────────────────────────────────────
418
+ // v0.13.2 — real device capability from `useCapture` (which uses
419
+ // `selectCaptureDevice`). True only when the device actually exposes
420
+ // an ultra-wide reachable via a multi-cam zoom OR a standalone
421
+ // ultra-wide device; false on wide-only hardware (chip hides).
422
+ const has0_5x = capture.has0_5x;
360
423
  const incremental = (0, useIncrementalStitcher_1.useIncrementalStitcher)();
361
424
  const visionCameraRef = (0, react_1.useRef)(null);
362
425
  const arViewRef = (0, react_1.useRef)(null);
@@ -867,6 +930,44 @@ function Camera(props) {
867
930
  const handleARToggle = (0, react_1.useCallback)(() => {
868
931
  setArPreference((prev) => !prev);
869
932
  }, []);
933
+ // ── v0.13.0 — Flash control ─────────────────────────────────────
934
+ //
935
+ // `flashRequested` is what the host / built-in button asks for.
936
+ // `effectiveFlash` is what we drive into vision-camera (non-AR). AR
937
+ // mode forces 'off' (flash is hidden in AR; ARKit/ARCore own the
938
+ // device) so vision-camera — which isn't the active camera in AR —
939
+ // doesn't fight for it.
940
+ //
941
+ // v0.13.1 — the ACTIVE device's torch capability is the source of
942
+ // truth. The ultra-wide (0.5×) lens has no flash/torch unit on most
943
+ // phones, so vision-camera throws `flash-not-available` if we pass
944
+ // flash="on" while it's selected. `capture.device.hasTorch` (from
945
+ // vision-camera's device list) tells us definitively; we hide the
946
+ // flash control and force 'off' when the device can't flash.
947
+ // v0.13.2 — `capture.deviceHasTorch` reflects the MOUNTED device. In
948
+ // multi-cam mode this is the multi-cam device (has a torch → flash
949
+ // works on both 1× and 0.5× via zoom). In standalone-uw mode on 0.5×
950
+ // the mounted device is the torchless ultra-wide → flash hides.
951
+ const deviceHasTorch = capture.deviceHasTorch;
952
+ const flashRequested = controlledFlash ?? internalFlash;
953
+ const effectiveFlash = isAR || !deviceHasTorch ? 'off' : flashRequested;
954
+ const toggleFlash = (0, react_1.useCallback)(() => {
955
+ const next = flashRequested === 'on' ? 'off' : 'on';
956
+ if (controlledFlash == null)
957
+ setInternalFlash(next);
958
+ onFlashChange?.(next);
959
+ }, [flashRequested, controlledFlash, onFlashChange]);
960
+ // v0.13.1 — top-right control pills (flash + AR) stack vertically
961
+ // UNDER the settings affordance. Anchor depends on what's above:
962
+ // - headerTitle set → pills clear the CaptureHeader bar
963
+ // (title row ≈ topInset + ~36; guidance pill adds ~28 when present)
964
+ // - standalone gear → pills clear the 40px gear at topInset + 8
965
+ // - neither → pills start where the gear would be
966
+ const pillStackTop = headerTitle != null
967
+ ? insets.top + (headerGuidance != null ? 72 : 40)
968
+ : showSettingsButton
969
+ ? insets.top + 8 + 44
970
+ : insets.top + 8;
870
971
  // ── JSX ─────────────────────────────────────────────────────────
871
972
  return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
872
973
  inFlightTransition ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
@@ -878,7 +979,12 @@ function Camera(props) {
878
979
  // the very first buffered preview frame. Android takeSnapshot
879
980
  // works either way. Pattern matches AuditCaptureScreen.tsx
880
981
  // which has run on `video` (true) for months without issue.
881
- video: true, flash: "off", style: react_native_1.StyleSheet.absoluteFill,
982
+ video: true, flash: effectiveFlash,
983
+ // v0.13.2 — in multi-cam mode the lens is switched via zoom
984
+ // on a single mounted device (0.5× → ultra-wide end, 1× →
985
+ // wide baseline). undefined in standalone/wide-only modes
986
+ // (lens = device identity, no zoom).
987
+ zoom: capture.deviceZoom, style: react_native_1.StyleSheet.absoluteFill,
882
988
  // F8 (FrameProcessor port) — host-supplied worklet runs on
883
989
  // the camera producer thread for every frame. Only wired
884
990
  // in non-AR mode; AR mode uses ARCameraView which doesn't
@@ -905,18 +1011,43 @@ function Camera(props) {
905
1011
  react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { topInset: insets.top }),
906
1012
  react_1.default.createElement(CaptureDebugOverlay_1.CaptureDebugOverlay, { incrementalState: incrementalState, imuTranslationMetres: isNonAR ? imuGate.getTranslationMetres() : null, captureSource: effectiveCaptureSource, frameSelectionMode: settings.frameSelection.mode, stitchMode: settings.stitcher.stitchMode }))),
907
1013
  react_1.default.createElement(CaptureStitchStatsToast_1.CaptureStitchStatsToast, { message: stitchToast.message, topInset: insets.top }),
908
- showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) })),
1014
+ headerTitle != null ? (react_1.default.createElement(react_native_1.View, { style: styles.headerWrap, pointerEvents: "box-none" },
1015
+ react_1.default.createElement(CaptureHeader_1.CaptureHeader, { title: headerTitle, onBack: onHeaderBack, backLabel: headerBackLabel, guidance: headerGuidance, colors: headerColors, topInset: insets.top, onSettingsPress: showSettingsButton
1016
+ ? () => setSettingsModalVisible(true)
1017
+ : undefined }))) : (showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) }))),
909
1018
  react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: bottomAreaStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation), insets.bottom + 12, insets.top + 12) },
910
1019
  statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation, vertical: isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) })),
1020
+ thumbnails != null && statusPhase !== 'recording' && (react_1.default.createElement(CaptureThumbnailStrip_1.CaptureThumbnailStrip, { items: thumbnails, minPhotos: thumbnailsMin, maxPhotos: thumbnailsMax, onItemPress: onThumbnailPress,
1021
+ // v0.13.1 — stack the idle strip vertically when the
1022
+ // home-indicator anchor is on a side edge (non-locked host
1023
+ // in landscape), matching PanoramaBandOverlay's `vertical`
1024
+ // so the strip rides the home-indicator edge instead of
1025
+ // running horizontally across the rotated screen.
1026
+ vertical: isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)),
1027
+ // v0.13.1 — counter-rotate the thumbnail images so the
1028
+ // captured scene reads upright in portrait-locked landscape.
1029
+ contentRotation: contentRotation })),
911
1030
  react_1.default.createElement(react_native_1.View, { style: bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) },
912
1031
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }),
913
1032
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarCenter },
914
- react_1.default.createElement(LensChip, { lens: lens, onChange: handleLensChange, has0_5x: has0_5x }),
1033
+ !arOnly && (react_1.default.createElement(LensChip, { lens: lens, onChange: handleLensChange, has0_5x: has0_5x, contentRotation: contentRotation })),
915
1034
  react_1.default.createElement(react_native_1.View, { style: styles.shutterWrap },
916
1035
  react_1.default.createElement(CameraShutter_1.CameraShutter, { onTap: handleTap, onHoldStart: enablePanoramaMode ? handleHoldStart : noop, onHoldComplete: enablePanoramaMode ? handleHoldEnd : noop, isProcessing: statusPhase === 'stitching', disabled: statusPhase === 'stitching' }))),
917
- react_1.default.createElement(react_native_1.View, { style: styles.bottomBarRight }, lens === '1x' && isARSupportedOnDevice && (react_1.default.createElement(ARToggle, { arEnabled: arPreference, onToggle: handleARToggle }))))),
1036
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBarRight }))),
1037
+ react_1.default.createElement(react_native_1.View, { style: [styles.pillStack, { top: pillStackTop }], pointerEvents: "box-none" },
1038
+ arAllowed && nonArAllowed && lens === '1x' && isARSupportedOnDevice && (react_1.default.createElement(ARToggle, { arEnabled: arPreference, onToggle: handleARToggle, contentRotation: contentRotation })),
1039
+ showFlashButton && !isAR && deviceHasTorch && (react_1.default.createElement(react_native_1.Pressable, { onPress: toggleFlash, accessibilityRole: "button", accessibilityLabel: `Flash ${flashRequested === 'on' ? 'on' : 'off'}`, accessibilityState: { selected: flashRequested === 'on' }, hitSlop: 8, style: [
1040
+ pillStyles.pill,
1041
+ flashRequested === 'on' && pillStyles.pillActive,
1042
+ ] },
1043
+ react_1.default.createElement(react_native_1.Text, { style: [
1044
+ pillStyles.flashGlyph,
1045
+ flashRequested === 'on' && pillStyles.glyphActive,
1046
+ contentRotation,
1047
+ ] }, "\u26A1")))),
918
1048
  react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
919
- react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) })));
1049
+ react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) }),
1050
+ react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: capturePreview != null, imageUri: capturePreview?.imageUri ?? '', imageWidth: capturePreview?.imageWidth, imageHeight: capturePreview?.imageHeight, title: capturePreview?.title, actions: capturePreviewActions, onClose: onCapturePreviewClose ?? noop })));
920
1051
  }
921
1052
  function noop() {
922
1053
  /* no-op handler used when panorama mode is disabled */
@@ -938,6 +1069,16 @@ function homeIndicatorEdge(jsLandscape, deviceOrient) {
938
1069
  function isSideEdge(edge) {
939
1070
  return edge === 'left' || edge === 'right';
940
1071
  }
1072
+ // v0.13.1 — test-only exports of the pure orientation-decision
1073
+ // functions. `homeIndicatorEdge` + `isSideEdge` together produce the
1074
+ // `vertical` flag that drives PanoramaBandOverlay and
1075
+ // CaptureThumbnailStrip layout, so they carry the orientation contract.
1076
+ // Unit-tested via these handles (the lib's jest config is pure-TS and
1077
+ // can't mount <Camera>; see jest.config.js).
1078
+ /** @internal test-only — see `homeIndicatorEdge`. */
1079
+ exports._homeIndicatorEdgeForTests = homeIndicatorEdge;
1080
+ /** @internal test-only — see `isSideEdge`. */
1081
+ exports._isSideEdgeForTests = isSideEdge;
941
1082
  /**
942
1083
  * v0.12.0 — bottom-controls outer container positioning. Anchors
943
1084
  * to the home-indicator JS edge with the appropriate flex direction
@@ -1045,6 +1186,8 @@ const styles = react_native_1.StyleSheet.create({
1045
1186
  },
1046
1187
  bottomBarLeft: {
1047
1188
  flex: 1,
1189
+ alignItems: 'flex-start',
1190
+ justifyContent: 'flex-end',
1048
1191
  },
1049
1192
  bottomBarCenter: {
1050
1193
  flex: 1,
@@ -1058,5 +1201,50 @@ const styles = react_native_1.StyleSheet.create({
1058
1201
  shutterWrap: {
1059
1202
  marginTop: 12,
1060
1203
  },
1204
+ headerWrap: {
1205
+ position: 'absolute',
1206
+ top: 0,
1207
+ left: 0,
1208
+ right: 0,
1209
+ },
1210
+ // v0.13.1 — `thumbnailStripWrap` removed. The strip now renders
1211
+ // inside the orientation-aware bottomArea container (alongside
1212
+ // PanoramaBandOverlay and the bottom bar) rather than as a
1213
+ // position-absolute overlay at hard-coded `bottom: 160`.
1214
+ //
1215
+ // v0.13.1 — top-right control pill stack (flash + AR). Absolute,
1216
+ // pinned to the right edge under the settings affordance; `top` is
1217
+ // set inline from `pillStackTop`. Column so the pills stack
1218
+ // vertically; gap keeps them from touching.
1219
+ pillStack: {
1220
+ position: 'absolute',
1221
+ right: 14,
1222
+ alignItems: 'flex-end',
1223
+ gap: 10,
1224
+ },
1225
+ });
1226
+ // v0.13.1 — shared pill style for the top-right control stack. The
1227
+ // flash pill matches the AR toggle's shape (same padding / radius /
1228
+ // background) so the two read as a set.
1229
+ const pillStyles = react_native_1.StyleSheet.create({
1230
+ pill: {
1231
+ paddingHorizontal: 14,
1232
+ paddingVertical: 8,
1233
+ borderRadius: 16,
1234
+ backgroundColor: 'rgba(0,0,0,0.45)',
1235
+ minWidth: 56,
1236
+ alignItems: 'center',
1237
+ justifyContent: 'center',
1238
+ },
1239
+ pillActive: {
1240
+ backgroundColor: '#ffd34d',
1241
+ },
1242
+ flashGlyph: {
1243
+ color: '#ffffff',
1244
+ fontSize: 18,
1245
+ },
1246
+ glyphActive: {
1247
+ color: '#1a1a1a',
1248
+ },
1061
1249
  });
1062
1250
  //# sourceMappingURL=Camera.js.map
@@ -25,6 +25,12 @@ export interface CameraViewProps {
25
25
  device: CameraDevice | null | undefined;
26
26
  /** Flash / torch state from ``useCapture().flash``. */
27
27
  flash?: 'off' | 'on';
28
+ /**
29
+ * v0.13.2 — zoom factor for the mounted device. Used in multi-cam
30
+ * mode to switch lenses (0.5× ultra-wide ↔ 1× wide) on a single
31
+ * device. `undefined` leaves vision-camera at its default zoom.
32
+ */
33
+ zoom?: number;
28
34
  /** Whether the preview is actively rendering. Defaults to true. */
29
35
  isActive?: boolean;
30
36
  /**
@@ -73,7 +73,7 @@ const VC_LIFECYCLE_ERROR_CODES = new Set([
73
73
  'system/camera-has-been-disconnected', // another app grabbed the camera
74
74
  'device/camera-already-in-use', // same class as above
75
75
  ]);
76
- exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash = 'off', isActive = true, video = false, guidance, style, cameraProps, onError, }, ref) {
76
+ exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash = 'off', zoom, isActive = true, video = false, guidance, style, cameraProps, onError, }, ref) {
77
77
  // Error filter — see `VC_LIFECYCLE_ERROR_CODES` for the swallow
78
78
  // list rationale. `code` on vision-camera's `CameraRuntimeError`
79
79
  // is typed as a string; treat any non-string defensively as a
@@ -97,7 +97,7 @@ exports.CameraView = (0, react_1.forwardRef)(function CameraView({ device, flash
97
97
  react_1.default.createElement(react_native_1.Text, { style: styles.placeholderText }, "Initialising camera\u2026")));
98
98
  }
99
99
  return (react_1.default.createElement(react_native_1.View, { style: [styles.root, style] },
100
- react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: innerRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: isActive, photo: true, video: video,
100
+ react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: innerRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: isActive, photo: true, video: video, ...(zoom != null ? { zoom } : {}),
101
101
  // Bake the device orientation into the captured pixels.
102
102
  // Without this, vision-camera writes the file in the camera
103
103
  // sensor's native landscape and relies on EXIF metadata to