react-native-image-stitcher 0.13.0 → 0.14.1

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 +115 -0
  2. package/README.md +238 -62
  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 +71 -16
  6. package/dist/camera/Camera.js +167 -51
  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 +281 -118
  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
@@ -44,7 +44,19 @@ import type { DrawableFrameProcessor, ReadonlyFrameProcessor } from 'react-nativ
44
44
  import { type CaptureHeaderProps } from './CaptureHeader';
45
45
  import { type CapturePreviewAction } from './CapturePreview';
46
46
  import { type CaptureThumbnailItem } from './CaptureThumbnailStrip';
47
+ import { type DeviceOrientation } from './useDeviceOrientation';
47
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';
48
60
  export type CameraLens = '1x' | '0.5x';
49
61
  export type StitchMode = 'auto' | 'panorama' | 'scans';
50
62
  export type Blender = 'multiband' | 'feather';
@@ -142,6 +154,19 @@ export interface CameraProps {
142
154
  enablePhotoMode?: boolean;
143
155
  enablePanoramaMode?: boolean;
144
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;
145
170
  style?: StyleProp<ViewStyle>;
146
171
  /**
147
172
  * Which incremental stitcher engine to drive. Default
@@ -256,22 +281,6 @@ export interface CameraProps {
256
281
  * `flash` prop) can opt out by setting this to `false`.
257
282
  */
258
283
  showFlashButton?: boolean;
259
- /**
260
- * v0.13.0 — show the built-in IncrementalPanGuide ("keep the
261
- * arrow on the line" drift marker) while recording. Defaults
262
- * to `true`. The guide is gyroscope-driven and only active
263
- * during the recording phase (no idle sensor cost). Hosts that
264
- * want their own pan-guide chrome can opt out via `false`.
265
- */
266
- panGuide?: boolean;
267
- /**
268
- * v0.13.0 — show the built-in PanoramaGuidance pan-speed pill
269
- * ("Pan slowly" / "Slow down" / "Too fast") while recording.
270
- * Defaults to `true`. Gyroscope-driven, only active during
271
- * recording. Hosts that want their own speed chrome can opt
272
- * out via `false`.
273
- */
274
- panoramaGuidance?: boolean;
275
284
  /**
276
285
  * v0.13.0 — built-in CaptureHeader title. When set, `<Camera>`
277
286
  * renders a top-of-screen header showing this title (centred)
@@ -476,4 +485,50 @@ export interface CameraProps {
476
485
  * The public `<Camera>` component.
477
486
  */
478
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 {};
479
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");
@@ -92,15 +92,14 @@ const CaptureMemoryPill_1 = require("./CaptureMemoryPill");
92
92
  const CaptureKeyframePill_1 = require("./CaptureKeyframePill");
93
93
  const CaptureOrientationPill_1 = require("./CaptureOrientationPill");
94
94
  const CaptureStitchStatsToast_1 = require("./CaptureStitchStatsToast");
95
- const IncrementalPanGuide_1 = require("./IncrementalPanGuide");
96
95
  const PanoramaBandOverlay_1 = require("./PanoramaBandOverlay");
97
- const PanoramaGuidance_1 = require("./PanoramaGuidance");
98
96
  const PanoramaSettingsBridge_1 = require("./PanoramaSettingsBridge");
99
97
  const PanoramaSettingsModal_1 = require("./PanoramaSettingsModal");
100
98
  const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings");
101
99
  const lowMemDevice_1 = require("./lowMemDevice");
102
100
  const useCapture_1 = require("./useCapture");
103
101
  const useDeviceOrientation_1 = require("./useDeviceOrientation");
102
+ const useContentRotation_1 = require("./useContentRotation");
104
103
  const useOrientationDrift_1 = require("./useOrientationDrift");
105
104
  const OrientationDriftModal_1 = require("./OrientationDriftModal");
106
105
  const incremental_1 = require("../stitching/incremental");
@@ -118,10 +117,10 @@ class CameraError extends Error {
118
117
  }
119
118
  }
120
119
  exports.CameraError = CameraError;
121
- function LensChip({ lens, onChange, has0_5x }) {
120
+ function LensChip({ lens, onChange, has0_5x, contentRotation }) {
122
121
  if (!has0_5x) {
123
122
  return (react_1.default.createElement(react_native_1.View, { style: [lensChipStyles.container, lensChipStyles.singleLens] },
124
- 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")));
125
124
  }
126
125
  return (react_1.default.createElement(react_native_1.View, { style: lensChipStyles.container },
127
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: [
@@ -131,6 +130,7 @@ function LensChip({ lens, onChange, has0_5x }) {
131
130
  react_1.default.createElement(react_native_1.Text, { style: [
132
131
  lensChipStyles.label,
133
132
  lens === '0.5x' && lensChipStyles.labelActive,
133
+ contentRotation,
134
134
  ] }, "0.5\u00D7")),
135
135
  react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange('1x'), accessibilityRole: "button", accessibilityLabel: "1x wide-angle lens", accessibilityState: { selected: lens === '1x' }, style: [
136
136
  lensChipStyles.pill,
@@ -139,6 +139,7 @@ function LensChip({ lens, onChange, has0_5x }) {
139
139
  react_1.default.createElement(react_native_1.Text, { style: [
140
140
  lensChipStyles.label,
141
141
  lens === '1x' && lensChipStyles.labelActive,
142
+ contentRotation,
142
143
  ] }, "1\u00D7"))));
143
144
  }
144
145
  const lensChipStyles = react_native_1.StyleSheet.create({
@@ -171,11 +172,12 @@ const lensChipStyles = react_native_1.StyleSheet.create({
171
172
  color: '#1a1a1a',
172
173
  },
173
174
  });
174
- function ARToggle({ arEnabled, onToggle }) {
175
+ function ARToggle({ arEnabled, onToggle, contentRotation }) {
175
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] },
176
177
  react_1.default.createElement(react_native_1.Text, { style: [
177
178
  arToggleStyles.label,
178
179
  arEnabled && arToggleStyles.labelOn,
180
+ contentRotation,
179
181
  ] }, "AR")));
180
182
  }
181
183
  const arToggleStyles = react_native_1.StyleSheet.create({
@@ -277,7 +279,14 @@ function extractPanoramaOverrides(props) {
277
279
  * The public `<Camera>` component.
278
280
  */
279
281
  function Camera(props) {
280
- const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, panGuide = true, panoramaGuidance = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, 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';
281
290
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
282
291
  // v0.12.0 — JS-layout orientation independent of device-physical.
283
292
  // `useWindowDimensions().width > height` tells us if the OS
@@ -288,8 +297,13 @@ function Camera(props) {
288
297
  const jsWindow = (0, react_native_1.useWindowDimensions)();
289
298
  const jsLandscape = jsWindow.width > jsWindow.height;
290
299
  // ── State ───────────────────────────────────────────────────────
291
- const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
292
- 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);
293
307
  // v0.13.0 — flash state. Controlled by `controlledFlash` when the
294
308
  // host supplies the `flash` prop; otherwise owned internally and
295
309
  // toggled by the built-in flash button. `effectiveFlash` below
@@ -319,6 +333,14 @@ function Camera(props) {
319
333
  const isAR = effectiveCaptureSource === 'ar';
320
334
  const isNonAR = !isAR;
321
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)();
322
344
  // ── Camera handoff gate ─────────────────────────────────────────
323
345
  //
324
346
  // The placeholder rendered while the underlying camera identity
@@ -347,6 +369,33 @@ function Camera(props) {
347
369
  const inFlightTransition = settledIsARRef.current !== isAR
348
370
  || settledLensRef.current !== lens
349
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
+ }, []);
350
399
  // ── Notify parent of capture-source changes ─────────────────────
351
400
  const lastEmittedSourceRef = (0, react_1.useRef)(null);
352
401
  (0, react_1.useEffect)(() => {
@@ -355,19 +404,22 @@ function Camera(props) {
355
404
  onCaptureSourceChange?.(effectiveCaptureSource);
356
405
  }
357
406
  }, [effectiveCaptureSource, onCaptureSourceChange]);
358
- // ── Lens chip availability ──────────────────────────────────────
359
- // TODO follow-up: probe the device's available physical lenses via
360
- // vision-camera's `useCameraDevices` and surface in
361
- // `useCapture().availablePhysicalDevices`. For now we assume the
362
- // 0.5x ultra-wide exists on modern devices. When it doesn't, the
363
- // lens chip degenerates to a static 1× indicator (see LensChip).
364
- const has0_5x = true;
365
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.
366
412
  const capture = (0, useCapture_1.useCapture)({
367
413
  cameraPosition: 'back',
368
414
  enableQualityChecks: false,
369
- preferredPhysicalDevice: lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
415
+ lens,
370
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;
371
423
  const incremental = (0, useIncrementalStitcher_1.useIncrementalStitcher)();
372
424
  const visionCameraRef = (0, react_1.useRef)(null);
373
425
  const arViewRef = (0, react_1.useRef)(null);
@@ -881,19 +933,41 @@ function Camera(props) {
881
933
  // ── v0.13.0 — Flash control ─────────────────────────────────────
882
934
  //
883
935
  // `flashRequested` is what the host / built-in button asks for.
884
- // `effectiveFlash` is what we actually drive into vision-camera
885
- // AR mode forces 'off' because ARKit / ARCore own AVCaptureDevice
886
- // and the torch isn't exposed. This way the button's visual state
887
- // (a11y, styling) tracks `flashRequested` while the underlying
888
- // camera always sees the correct value.
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;
889
952
  const flashRequested = controlledFlash ?? internalFlash;
890
- const effectiveFlash = isAR ? 'off' : flashRequested;
953
+ const effectiveFlash = isAR || !deviceHasTorch ? 'off' : flashRequested;
891
954
  const toggleFlash = (0, react_1.useCallback)(() => {
892
955
  const next = flashRequested === 'on' ? 'off' : 'on';
893
956
  if (controlledFlash == null)
894
957
  setInternalFlash(next);
895
958
  onFlashChange?.(next);
896
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;
897
971
  // ── JSX ─────────────────────────────────────────────────────────
898
972
  return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
899
973
  inFlightTransition ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
@@ -905,7 +979,12 @@ function Camera(props) {
905
979
  // the very first buffered preview frame. Android takeSnapshot
906
980
  // works either way. Pattern matches AuditCaptureScreen.tsx
907
981
  // which has run on `video` (true) for months without issue.
908
- video: true, flash: effectiveFlash, 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,
909
988
  // F8 (FrameProcessor port) — host-supplied worklet runs on
910
989
  // the camera producer thread for every frame. Only wired
911
990
  // in non-AR mode; AR mode uses ARCameraView which doesn't
@@ -926,8 +1005,6 @@ function Camera(props) {
926
1005
  onError?.(new CameraError('VISION_CAMERA_RUNTIME', `${codeStr}: ${msg}`, err));
927
1006
  } })),
928
1007
  react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
929
- panGuide && (react_1.default.createElement(IncrementalPanGuide_1.IncrementalPanGuide, { active: statusPhase === 'recording' })),
930
- panoramaGuidance && (react_1.default.createElement(PanoramaGuidance_1.PanoramaGuidance, { active: statusPhase === 'recording' })),
931
1008
  settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
932
1009
  react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
933
1010
  react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
@@ -938,22 +1015,36 @@ function Camera(props) {
938
1015
  react_1.default.createElement(CaptureHeader_1.CaptureHeader, { title: headerTitle, onBack: onHeaderBack, backLabel: headerBackLabel, guidance: headerGuidance, colors: headerColors, topInset: insets.top, onSettingsPress: showSettingsButton
939
1016
  ? () => setSettingsModalVisible(true)
940
1017
  : undefined }))) : (showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) }))),
941
- thumbnails != null && statusPhase !== 'recording' && (react_1.default.createElement(react_native_1.View, { style: styles.thumbnailStripWrap, pointerEvents: "box-none" },
942
- react_1.default.createElement(CaptureThumbnailStrip_1.CaptureThumbnailStrip, { items: thumbnails, minPhotos: thumbnailsMin, maxPhotos: thumbnailsMax, onItemPress: onThumbnailPress }))),
943
1018
  react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: bottomAreaStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation), insets.bottom + 12, insets.top + 12) },
944
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 })),
945
1030
  react_1.default.createElement(react_native_1.View, { style: bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation)) },
946
- react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }, showFlashButton && (react_1.default.createElement(react_native_1.Pressable, { onPress: isAR ? undefined : toggleFlash, accessibilityRole: "button", accessibilityLabel: isAR ? 'Flash unavailable in AR mode' : `Flash ${flashRequested === 'on' ? 'on' : 'off'}`, accessibilityState: { selected: flashRequested === 'on', disabled: isAR }, disabled: isAR, hitSlop: 8, style: [
947
- styles.flashButton,
948
- flashRequested === 'on' && !isAR && styles.flashButtonActive,
949
- isAR && styles.flashButtonDisabled,
950
- ] },
951
- react_1.default.createElement(react_native_1.Text, { style: styles.flashIcon }, "\u26A1")))),
1031
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }),
952
1032
  react_1.default.createElement(react_native_1.View, { style: styles.bottomBarCenter },
953
- 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 })),
954
1034
  react_1.default.createElement(react_native_1.View, { style: styles.shutterWrap },
955
1035
  react_1.default.createElement(CameraShutter_1.CameraShutter, { onTap: handleTap, onHoldStart: enablePanoramaMode ? handleHoldStart : noop, onHoldComplete: enablePanoramaMode ? handleHoldEnd : noop, isProcessing: statusPhase === 'stitching', disabled: statusPhase === 'stitching' }))),
956
- 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")))),
957
1048
  react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
958
1049
  react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) }),
959
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 })));
@@ -978,6 +1069,16 @@ function homeIndicatorEdge(jsLandscape, deviceOrient) {
978
1069
  function isSideEdge(edge) {
979
1070
  return edge === 'left' || edge === 'right';
980
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;
981
1082
  /**
982
1083
  * v0.12.0 — bottom-controls outer container positioning. Anchors
983
1084
  * to the home-indicator JS edge with the appropriate flex direction
@@ -1106,29 +1207,44 @@ const styles = react_native_1.StyleSheet.create({
1106
1207
  left: 0,
1107
1208
  right: 0,
1108
1209
  },
1109
- thumbnailStripWrap: {
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: {
1110
1220
  position: 'absolute',
1111
- left: 0,
1112
- right: 0,
1113
- bottom: 160,
1221
+ right: 14,
1222
+ alignItems: 'flex-end',
1223
+ gap: 10,
1114
1224
  },
1115
- flashButton: {
1116
- width: 44,
1117
- height: 44,
1118
- borderRadius: 22,
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,
1119
1236
  alignItems: 'center',
1120
1237
  justifyContent: 'center',
1121
- backgroundColor: 'rgba(0,0,0,0.45)',
1122
1238
  },
1123
- flashButtonActive: {
1239
+ pillActive: {
1124
1240
  backgroundColor: '#ffd34d',
1125
1241
  },
1126
- flashButtonDisabled: {
1127
- opacity: 0.35,
1128
- },
1129
- flashIcon: {
1130
- fontSize: 20,
1242
+ flashGlyph: {
1131
1243
  color: '#ffffff',
1244
+ fontSize: 18,
1245
+ },
1246
+ glyphActive: {
1247
+ color: '#1a1a1a',
1132
1248
  },
1133
1249
  });
1134
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
@@ -27,20 +27,26 @@ exports.CaptureHeader = CaptureHeader;
27
27
  const react_1 = __importDefault(require("react"));
28
28
  const react_native_1 = require("react-native");
29
29
  function CaptureHeader({ title, onBack, backLabel = '‹ Back', onSettingsPress, guidance, topInset = 0, colors, style, }) {
30
- const bg = colors?.background ?? '#000000';
30
+ // v0.13.1 defaults are now transparent over the camera preview
31
+ // (matches the AR toggle / settings gear pill style); hosts using
32
+ // the header outside a camera context can pass solid colours via
33
+ // `colors`. Title + gear get a text shadow for legibility over
34
+ // bright preview content; guidance row keeps a translucent pill
35
+ // background for the same reason.
36
+ const bg = colors?.background ?? 'transparent';
31
37
  const titleColor = colors?.title ?? '#ffffff';
32
38
  const accent = colors?.accent ?? '#FF9F0A';
33
- const guidanceBg = colors?.guidanceBackground ?? 'rgba(255,255,255,0.08)';
39
+ const guidanceBg = colors?.guidanceBackground ?? 'rgba(0,0,0,0.45)';
34
40
  const guidanceColor = colors?.guidanceText ?? '#ffffff';
35
41
  return (react_1.default.createElement(react_native_1.View, { style: [{ backgroundColor: bg }, style] },
36
- react_1.default.createElement(react_native_1.View, { style: [styles.titleRow, { paddingTop: topInset + 8 }] },
42
+ react_1.default.createElement(react_native_1.View, { style: [styles.titleRow, { paddingTop: topInset + 4 }] },
37
43
  onBack ? (react_1.default.createElement(react_native_1.Pressable, { onPress: onBack, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: "Go back", style: styles.backButton },
38
- react_1.default.createElement(react_native_1.Text, { style: [styles.backText, { color: accent }] }, backLabel))) : (
44
+ react_1.default.createElement(react_native_1.Text, { style: [styles.backText, styles.textShadow, { color: accent }] }, backLabel))) : (
39
45
  // Empty spacer keeps the title centred even when back is hidden.
40
46
  react_1.default.createElement(react_native_1.View, { style: styles.backButton })),
41
- react_1.default.createElement(react_native_1.Text, { style: [styles.title, { color: titleColor }], numberOfLines: 1, accessibilityRole: "header" }, title),
47
+ react_1.default.createElement(react_native_1.Text, { style: [styles.title, styles.textShadow, { color: titleColor }], numberOfLines: 1, accessibilityRole: "header" }, title),
42
48
  onSettingsPress ? (react_1.default.createElement(react_native_1.Pressable, { onPress: onSettingsPress, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: "Open panorama settings", style: styles.backButton },
43
- react_1.default.createElement(react_native_1.Text, { style: [styles.gearIcon, { color: accent }] }, "\u2699"))) : (react_1.default.createElement(react_native_1.View, { style: styles.backButton }))),
49
+ react_1.default.createElement(react_native_1.Text, { style: [styles.gearIcon, styles.textShadow, { color: accent }] }, "\u2699"))) : (react_1.default.createElement(react_native_1.View, { style: styles.backButton }))),
44
50
  guidance ? (react_1.default.createElement(react_native_1.View, { style: [styles.guidance, { backgroundColor: guidanceBg }], accessibilityRole: "text" },
45
51
  react_1.default.createElement(react_native_1.Text, { style: [styles.guidanceText, { color: guidanceColor }], numberOfLines: 2 }, guidance))) : null));
46
52
  }
@@ -49,33 +55,50 @@ const styles = react_native_1.StyleSheet.create({
49
55
  flexDirection: 'row',
50
56
  alignItems: 'center',
51
57
  justifyContent: 'space-between',
52
- paddingHorizontal: 16,
53
- paddingBottom: 8,
58
+ paddingHorizontal: 12,
59
+ paddingBottom: 4,
54
60
  },
55
61
  backButton: {
56
- minWidth: 64,
57
- paddingVertical: 4,
62
+ minWidth: 56,
63
+ paddingVertical: 2,
58
64
  },
59
65
  backText: {
60
- fontSize: 16,
66
+ fontSize: 14,
61
67
  fontWeight: '500',
62
68
  },
63
69
  title: {
64
70
  flex: 1,
65
71
  textAlign: 'center',
66
- fontSize: 16,
72
+ fontSize: 14,
67
73
  fontWeight: '600',
68
74
  },
69
75
  guidance: {
70
- paddingHorizontal: 16,
71
- paddingVertical: 8,
76
+ // v0.13.1 — guidance row is now a centred pill inset from the
77
+ // edges (matches the AR-toggle / lens-chip pill style) rather
78
+ // than a full-width band. The pill background gives it its
79
+ // own contrast over the preview without forcing a solid bar.
80
+ alignSelf: 'center',
81
+ marginTop: 4,
82
+ paddingHorizontal: 10,
83
+ paddingVertical: 5,
84
+ borderRadius: 12,
85
+ maxWidth: '90%',
72
86
  },
73
87
  guidanceText: {
74
- fontSize: 13,
88
+ fontSize: 12,
89
+ textAlign: 'center',
75
90
  },
76
91
  gearIcon: {
77
- fontSize: 22,
92
+ fontSize: 20,
78
93
  textAlign: 'right',
79
94
  },
95
+ // v0.13.1 — subtle text shadow so the (now-transparent) header
96
+ // text stays legible over bright preview content. Same trick
97
+ // iOS Camera uses for the timestamp / mode labels.
98
+ textShadow: {
99
+ textShadowColor: 'rgba(0,0,0,0.65)',
100
+ textShadowOffset: { width: 0, height: 1 },
101
+ textShadowRadius: 2,
102
+ },
80
103
  });
81
104
  //# sourceMappingURL=CaptureHeader.js.map
@@ -37,7 +37,19 @@ function CapturePreview({ visible, imageUri, imageWidth, imageHeight, actions, o
37
37
  ? imageWidth / imageHeight
38
38
  : 16 / 9;
39
39
  const hasActions = actions && actions.length > 0;
40
- return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true, onRequestClose: onClose },
40
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true,
41
+ // v0.13.1 — RN's iOS <Modal> defaults to portrait-only, which
42
+ // pins the stitched-image preview to portrait even when the host
43
+ // app is in landscape (the preview appeared sideways/letterboxed
44
+ // under a non-locked host). Declaring all four keeps the modal
45
+ // aligned with the interface. Mirrors the v0.12 fix already on
46
+ // OrientationDriftModal + PanoramaSettingsModal.
47
+ supportedOrientations: [
48
+ 'portrait',
49
+ 'portrait-upside-down',
50
+ 'landscape-left',
51
+ 'landscape-right',
52
+ ], onRequestClose: onClose },
41
53
  react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
42
54
  react_1.default.createElement(react_native_1.View, { style: styles.topBar },
43
55
  react_1.default.createElement(react_native_1.View, { style: styles.topBarSpacer }),