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
@@ -48,6 +48,7 @@ import React, {
48
48
  } from 'react';
49
49
  import {
50
50
  NativeModules,
51
+ Platform,
51
52
  Pressable,
52
53
  StyleSheet,
53
54
  Text,
@@ -67,6 +68,12 @@ import { useARSession } from '../ar/useARSession';
67
68
  import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
68
69
  import { CameraShutter } from './CameraShutter';
69
70
  import { CameraView } from './CameraView';
71
+ import { CaptureHeader, type CaptureHeaderProps } from './CaptureHeader';
72
+ import { CapturePreview, type CapturePreviewAction } from './CapturePreview';
73
+ import {
74
+ CaptureThumbnailStrip,
75
+ type CaptureThumbnailItem,
76
+ } from './CaptureThumbnailStrip';
70
77
  import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
71
78
  import { CaptureDebugOverlay } from './CaptureDebugOverlay';
72
79
  import { CaptureMemoryPill } from './CaptureMemoryPill';
@@ -84,6 +91,7 @@ import {
84
91
  import { isLowMemDevice } from './lowMemDevice';
85
92
  import { useCapture } from './useCapture';
86
93
  import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
94
+ import { useContentRotation } from './useContentRotation';
87
95
  import { useOrientationDrift } from './useOrientationDrift';
88
96
  import { OrientationDriftModal } from './OrientationDriftModal';
89
97
  import {
@@ -107,6 +115,17 @@ import {
107
115
  // ─── Types ──────────────────────────────────────────────────────────
108
116
 
109
117
  export type CaptureSource = 'ar' | 'non-ar';
118
+ /**
119
+ * v0.13.2 — which capture sources the host ALLOWS. A constraint on top
120
+ * of `defaultCaptureSource` (which picks the initial source within this
121
+ * constraint):
122
+ * 'both' — AR and non-AR both available; AR toggle is shown.
123
+ * 'ar' — AR only; AR toggle hidden (nothing to switch to), and the
124
+ * 0.5× lens chooser is hidden (ARKit/ARCore don't expose the
125
+ * ultra-wide).
126
+ * 'non-ar' — non-AR only; AR toggle hidden.
127
+ */
128
+ export type CaptureSourcesMode = 'ar' | 'non-ar' | 'both';
110
129
  export type CameraLens = '1x' | '0.5x';
111
130
  export type StitchMode = 'auto' | 'panorama' | 'scans';
112
131
  export type Blender = 'multiband' | 'feather';
@@ -235,6 +254,19 @@ export interface CameraProps {
235
254
  enablePhotoMode?: boolean;
236
255
  enablePanoramaMode?: boolean;
237
256
  showSettingsButton?: boolean;
257
+ /**
258
+ * v0.13.2 — which capture sources the host allows (default `'both'`).
259
+ * Constrains both the runtime AR toggle and `defaultCaptureSource`:
260
+ * - `'both'` : AR + non-AR; the AR toggle is shown so the user can
261
+ * switch at runtime.
262
+ * - `'ar'` : AR only. AR toggle hidden (nothing to toggle); the
263
+ * 0.5× lens chooser is also hidden (ARKit/ARCore can't use the
264
+ * ultra-wide), so the camera stays on the AR-capable 1× lens.
265
+ * - `'non-ar'`: non-AR only. AR toggle hidden.
266
+ * When set to a single source, that source wins regardless of
267
+ * `defaultCaptureSource`.
268
+ */
269
+ captureSources?: CaptureSourcesMode;
238
270
  style?: StyleProp<ViewStyle>;
239
271
 
240
272
  /**
@@ -317,6 +349,173 @@ export interface CameraProps {
317
349
  */
318
350
  onCaptureAbandoned?: (reason: 'orientation-drift') => void;
319
351
 
352
+ /**
353
+ * v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
354
+ *
355
+ * - **Uncontrolled** (omit `flash`): `<Camera>` owns the flash
356
+ * state internally. Tapping the built-in flash button toggles
357
+ * it on/off. `onFlashChange` (if supplied) fires for telemetry.
358
+ * - **Controlled** (supply `flash`): the parent owns the state.
359
+ * The built-in button still renders and fires `onFlashChange`
360
+ * on press, but it's a no-op unless the parent updates `flash`
361
+ * in response.
362
+ *
363
+ * Both shapes coexist with the v0.13 "flash button is on by default"
364
+ * built-in (see the bottom-left bar slot in the JSX). Hosts that
365
+ * want their own flash chrome can opt out via `showFlashButton={false}`
366
+ * and drive the underlying torch by controlling `flash` directly.
367
+ *
368
+ * ## AR-mode behaviour
369
+ *
370
+ * In AR mode (`defaultCaptureSource="ar"` or runtime-toggled),
371
+ * ARKit / ARCore own the `AVCaptureDevice` and don't expose the
372
+ * torch through vision-camera's pipeline. The built-in flash
373
+ * button renders as visibly disabled (a11y label "Flash unavailable
374
+ * in AR mode") and `flash` is forced to `'off'` regardless of
375
+ * controlled/uncontrolled state. Hosts that need flash should
376
+ * toggle to non-AR before enabling.
377
+ */
378
+ flash?: 'on' | 'off';
379
+
380
+ /**
381
+ * v0.13.0 — fires when the user taps the built-in flash button.
382
+ * In uncontrolled mode, the internal state has already flipped
383
+ * (single render delay). In controlled mode, the parent must
384
+ * update the `flash` prop in response or the visual toggle is
385
+ * a no-op. Useful in either mode for telemetry.
386
+ */
387
+ onFlashChange?: (next: 'on' | 'off') => void;
388
+
389
+ /**
390
+ * v0.13.0 — show the built-in flash button in the bottom-left
391
+ * slot. Defaults to `true`. Hosts that render their own flash
392
+ * chrome (and drive the underlying torch via the controlled
393
+ * `flash` prop) can opt out by setting this to `false`.
394
+ */
395
+ showFlashButton?: boolean;
396
+
397
+ /**
398
+ * v0.13.0 — built-in CaptureHeader title. When set, `<Camera>`
399
+ * renders a top-of-screen header showing this title (centred)
400
+ * with an optional back affordance + guidance subtitle + the
401
+ * existing settings gear absorbed into the header's right side.
402
+ *
403
+ * When `headerTitle` is undefined the header is not rendered
404
+ * (matches pre-v0.13 behaviour: top of preview is bare except
405
+ * for the standalone settings gear gated on `showSettingsButton`).
406
+ *
407
+ * Combine with `onHeaderBack`, `headerBackLabel`, `headerGuidance`,
408
+ * and `headerColors` to customise the rest of the header. Hosts
409
+ * that need richer header chrome can omit `headerTitle` and
410
+ * compose their own `<CaptureHeader>` above `<Camera>`.
411
+ */
412
+ headerTitle?: string;
413
+
414
+ /**
415
+ * v0.13.0 — header back-button callback. When supplied (and
416
+ * `headerTitle` is set), the header renders a back affordance
417
+ * on the left. Omitted ⇒ no back button (the title stays
418
+ * centred).
419
+ */
420
+ onHeaderBack?: () => void;
421
+
422
+ /**
423
+ * v0.13.0 — header back-button label. Defaults to "‹ Back".
424
+ * No effect unless `headerTitle` and `onHeaderBack` are both set.
425
+ */
426
+ headerBackLabel?: string;
427
+
428
+ /**
429
+ * v0.13.0 — optional second-line subtitle shown below the
430
+ * header title. E.g. "Photograph the promotional cola end cap."
431
+ * Renders nothing when undefined. No effect unless `headerTitle`
432
+ * is set.
433
+ */
434
+ headerGuidance?: string;
435
+
436
+ /**
437
+ * v0.13.0 — colour overrides for the built-in header. Defaults
438
+ * are white-on-black to stay legible over the camera preview.
439
+ * No effect unless `headerTitle` is set.
440
+ */
441
+ headerColors?: CaptureHeaderProps['colors'];
442
+
443
+ /**
444
+ * v0.13.0 — when provided (even as `[]`), `<Camera>` renders a
445
+ * built-in `CaptureThumbnailStrip` above the bottom controls
446
+ * showing the host's capture history. Each item is a plain
447
+ * `{ id, uri, width?, height? }` object; the strip handles
448
+ * aspect-ratio rendering, tap-to-preview, and the count line.
449
+ *
450
+ * Omit (`undefined`) to skip the strip entirely. Hosts using
451
+ * the strip independently (e.g. on a non-camera screen) can keep
452
+ * importing `CaptureThumbnailStrip` directly from the library —
453
+ * the prop here is the convenience wiring for in-`<Camera>` use.
454
+ *
455
+ * Captures emitted by `<Camera>`'s `onCapture` are NOT added to
456
+ * this array automatically — the host owns the canonical list
457
+ * (typically persisted to its own DB) and updates the prop in
458
+ * response. This matches the SDK's "Camera owns runtime state,
459
+ * host persists" pattern.
460
+ */
461
+ thumbnails?: CaptureThumbnailItem[];
462
+
463
+ /**
464
+ * v0.13.0 — minimum-photos hint for the count line. Renders
465
+ * "n / minPhotos min" with the success colour when reached,
466
+ * warning colour otherwise.
467
+ */
468
+ thumbnailsMin?: number;
469
+
470
+ /**
471
+ * v0.13.0 — maximum-photos hint for the count line. Renders
472
+ * "· maxPhotos max" suffix. No enforcement — the host decides
473
+ * what to do at the cap.
474
+ */
475
+ thumbnailsMax?: number;
476
+
477
+ /**
478
+ * v0.13.0 — tap handler for thumbnails. When set, replaces the
479
+ * strip's built-in tap-to-preview modal; the host shows its own
480
+ * preview UI (e.g. with delete / recapture buttons gated on
481
+ * sync state). Omit to use the built-in preview.
482
+ */
483
+ onThumbnailPress?: (item: CaptureThumbnailItem) => void;
484
+
485
+ /**
486
+ * v0.13.0 — when set, `<Camera>` renders a built-in `CapturePreview`
487
+ * modal as `visible`. Use this for post-stitch confirmation:
488
+ * after `onCapture` emits, the host stores the result and sets
489
+ * `capturePreview` to the new image, with `capturePreviewActions`
490
+ * = `[Discard, Save]` (or similar). Setting `undefined` hides
491
+ * the modal.
492
+ *
493
+ * Hosts using the modal for thumbnail tap-to-preview can leave
494
+ * this undefined and let the built-in strip's preview handle
495
+ * that case.
496
+ */
497
+ capturePreview?: {
498
+ imageUri: string;
499
+ imageWidth?: number;
500
+ imageHeight?: number;
501
+ title?: string;
502
+ };
503
+
504
+ /**
505
+ * v0.13.0 — action buttons rendered along the bottom of the
506
+ * `CapturePreview` modal. Empty array (or undefined) renders
507
+ * no buttons, only the close affordance.
508
+ */
509
+ capturePreviewActions?: CapturePreviewAction[];
510
+
511
+ /**
512
+ * v0.13.0 — fires when the user dismisses the `capturePreview`
513
+ * modal (tap close, backdrop tap, hardware back on Android).
514
+ * The host is expected to clear the `capturePreview` prop in
515
+ * response.
516
+ */
517
+ onCapturePreviewClose?: () => void;
518
+
320
519
  /**
321
520
  * Optional host-supplied vision-camera frame processor.
322
521
  *
@@ -422,12 +621,19 @@ interface LensChipProps {
422
621
  lens: CameraLens;
423
622
  onChange: (lens: CameraLens) => void;
424
623
  has0_5x: boolean;
624
+ /**
625
+ * v0.13.1 — counter-rotation applied to the label TEXT (not the pill
626
+ * container) so the "0.5×"/"1×" glyphs read upright when the device
627
+ * is held landscape under a portrait-locked host, while the pill
628
+ * itself stays fixed in the layout. `{}` (no-op) in the upright cases.
629
+ */
630
+ contentRotation?: { transform?: ViewStyle['transform'] };
425
631
  }
426
- function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element {
632
+ function LensChip({ lens, onChange, has0_5x, contentRotation }: LensChipProps): React.JSX.Element {
427
633
  if (!has0_5x) {
428
634
  return (
429
635
  <View style={[lensChipStyles.container, lensChipStyles.singleLens]}>
430
- <Text style={lensChipStyles.label}>1×</Text>
636
+ <Text style={[lensChipStyles.label, contentRotation]}>1×</Text>
431
637
  </View>
432
638
  );
433
639
  }
@@ -447,6 +653,7 @@ function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element
447
653
  style={[
448
654
  lensChipStyles.label,
449
655
  lens === '0.5x' && lensChipStyles.labelActive,
656
+ contentRotation,
450
657
  ]}
451
658
  >
452
659
  0.5×
@@ -466,6 +673,7 @@ function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element
466
673
  style={[
467
674
  lensChipStyles.label,
468
675
  lens === '1x' && lensChipStyles.labelActive,
676
+ contentRotation,
469
677
  ]}
470
678
  >
471
679
 
@@ -515,8 +723,15 @@ const lensChipStyles = StyleSheet.create({
515
723
  interface ARToggleProps {
516
724
  arEnabled: boolean;
517
725
  onToggle: () => void;
726
+ /**
727
+ * v0.13.1 — counter-rotation applied to the "AR" label TEXT (not the
728
+ * pill container) so the glyph reads upright when the device is held
729
+ * landscape under a portrait-locked host, while the pill stays fixed.
730
+ * `{}` no-op in the upright cases.
731
+ */
732
+ contentRotation?: { transform?: ViewStyle['transform'] };
518
733
  }
519
- function ARToggle({ arEnabled, onToggle }: ARToggleProps): React.JSX.Element {
734
+ function ARToggle({ arEnabled, onToggle, contentRotation }: ARToggleProps): React.JSX.Element {
520
735
  return (
521
736
  <Pressable
522
737
  onPress={onToggle}
@@ -529,6 +744,7 @@ function ARToggle({ arEnabled, onToggle }: ARToggleProps): React.JSX.Element {
529
744
  style={[
530
745
  arToggleStyles.label,
531
746
  arEnabled && arToggleStyles.labelOn,
747
+ contentRotation,
532
748
  ]}
533
749
  >
534
750
  AR
@@ -670,6 +886,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
670
886
  const {
671
887
  defaultCaptureSource = 'ar',
672
888
  defaultLens = '1x',
889
+ captureSources = 'both',
673
890
  enablePhotoMode = true,
674
891
  enablePanoramaMode = true,
675
892
  showSettingsButton = false,
@@ -681,10 +898,33 @@ export function Camera(props: CameraProps): React.JSX.Element {
681
898
  onFramesDropped,
682
899
  onError,
683
900
  onCaptureAbandoned,
901
+ flash: controlledFlash,
902
+ onFlashChange,
903
+ showFlashButton = true,
904
+ headerTitle,
905
+ onHeaderBack,
906
+ headerBackLabel,
907
+ headerGuidance,
908
+ headerColors,
909
+ thumbnails,
910
+ thumbnailsMin,
911
+ thumbnailsMax,
912
+ onThumbnailPress,
913
+ capturePreview,
914
+ capturePreviewActions,
915
+ onCapturePreviewClose,
684
916
  frameProcessor: hostFrameProcessor,
685
917
  engine = 'batch-keyframe',
686
918
  } = props;
687
919
 
920
+ // v0.13.2 — capture-source constraint (default 'both'). Derives which
921
+ // sources are permitted; `captureSources` overrides any conflicting
922
+ // `defaultCaptureSource`. Used to constrain the initial AR preference
923
+ // and to hide the AR toggle / lens chooser below.
924
+ const arAllowed = captureSources !== 'non-ar';
925
+ const nonArAllowed = captureSources !== 'ar';
926
+ const arOnly = captureSources === 'ar';
927
+
688
928
  const insets = useSafeAreaInsets();
689
929
  // v0.12.0 — JS-layout orientation independent of device-physical.
690
930
  // `useWindowDimensions().width > height` tells us if the OS
@@ -696,10 +936,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
696
936
  const jsLandscape = jsWindow.width > jsWindow.height;
697
937
 
698
938
  // ── State ───────────────────────────────────────────────────────
939
+ // v0.13.2 — initial AR preference honours `defaultCaptureSource` but
940
+ // is clamped to the `captureSources` constraint: 'ar' forces on,
941
+ // 'non-ar' forces off, 'both' uses the default.
699
942
  const [arPreference, setArPreference] = useState(
700
- defaultCaptureSource === 'ar',
943
+ !arAllowed ? false : !nonArAllowed ? true : defaultCaptureSource === 'ar',
701
944
  );
702
- const [lens, setLens] = useState<CameraLens>(defaultLens);
945
+ // v0.13.2 — `arOnly` forces the 1× lens (the ultra-wide isn't usable
946
+ // in AR), and the lens chooser is hidden in that mode.
947
+ const [lens, setLens] = useState<CameraLens>(arOnly ? '1x' : defaultLens);
948
+ // v0.13.0 — flash state. Controlled by `controlledFlash` when the
949
+ // host supplies the `flash` prop; otherwise owned internally and
950
+ // toggled by the built-in flash button. `effectiveFlash` below
951
+ // also forces 'off' in AR mode (ARKit / ARCore own the device's
952
+ // torch and don't surface it through vision-camera's pipeline).
953
+ const [internalFlash, setInternalFlash] = useState<'on' | 'off'>('off');
703
954
  const [settings, setSettings] = useState<PanoramaSettings>(() =>
704
955
  buildPanoramaInitialSettings(
705
956
  extractPanoramaOverrides(props),
@@ -739,6 +990,15 @@ export function Camera(props: CameraProps): React.JSX.Element {
739
990
  const isNonAR = !isAR;
740
991
  const deviceOrientation = useDeviceOrientation();
741
992
 
993
+ // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
994
+ // pill, flash icon, thumbnails) so their labels read upright relative
995
+ // to gravity when the device is held landscape under a PORTRAIT-LOCKED
996
+ // host (the recommended config — the JS framebuffer stays portrait, so
997
+ // without this the labels render at 90°). Returns `{}` (no-op) in the
998
+ // common upright cases, including non-locked hosts where the OS already
999
+ // rotated the framebuffer. See `useContentRotation` truth table.
1000
+ const contentRotation = useContentRotation();
1001
+
742
1002
  // ── Camera handoff gate ─────────────────────────────────────────
743
1003
  //
744
1004
  // The placeholder rendered while the underlying camera identity
@@ -770,6 +1030,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
770
1030
  || cameraTransitioning;
771
1031
 
772
1032
 
1033
+ // ── v0.13.1 — Android portrait lock ─────────────────────────────
1034
+ //
1035
+ // Android lets a mounted view force its host Activity's orientation,
1036
+ // so `<Camera>` guarantees a portrait capture surface regardless of
1037
+ // the host app's manifest (even a landscape/unlocked host gets a
1038
+ // portrait camera while `<Camera>` is mounted). The lock lives on
1039
+ // the Activity via the native `RNSARSession` module, so it covers
1040
+ // BOTH the AR (ARCore) and non-AR (vision-camera) capture paths.
1041
+ //
1042
+ // iOS is intentionally NOT locked here: iOS supported orientations
1043
+ // are a static Info.plist declaration the host owns, and we want iOS
1044
+ // hosts to be able to support landscape/unlocked capture. Hosts that
1045
+ // want a portrait-only iOS app set UISupportedInterfaceOrientations
1046
+ // themselves.
1047
+ //
1048
+ // Empty dep array — lock on mount, restore the host's PRIOR
1049
+ // orientation on unmount (the native side captures it).
1050
+ useEffect(() => {
1051
+ if (Platform.OS !== 'android') return undefined;
1052
+ const arModule = (NativeModules as Record<string, unknown>)
1053
+ .RNSARSession as
1054
+ | { lockPortrait?: () => void; unlockOrientation?: () => void }
1055
+ | undefined;
1056
+ arModule?.lockPortrait?.();
1057
+ return () => {
1058
+ arModule?.unlockOrientation?.();
1059
+ };
1060
+ }, []);
1061
+
773
1062
  // ── Notify parent of capture-source changes ─────────────────────
774
1063
  const lastEmittedSourceRef = useRef<CaptureSource | null>(null);
775
1064
  useEffect(() => {
@@ -779,21 +1068,23 @@ export function Camera(props: CameraProps): React.JSX.Element {
779
1068
  }
780
1069
  }, [effectiveCaptureSource, onCaptureSourceChange]);
781
1070
 
782
- // ── Lens chip availability ──────────────────────────────────────
783
- // TODO follow-up: probe the device's available physical lenses via
784
- // vision-camera's `useCameraDevices` and surface in
785
- // `useCapture().availablePhysicalDevices`. For now we assume the
786
- // 0.5x ultra-wide exists on modern devices. When it doesn't, the
787
- // lens chip degenerates to a static 1× indicator (see LensChip).
788
- const has0_5x = true;
789
-
790
1071
  // ── Capture hooks ───────────────────────────────────────────────
1072
+ // v0.13.2 — pass the active `lens` so useCapture uses capability-aware
1073
+ // selection (multi-cam zoom-switch where available, standalone-ultra-
1074
+ // wide swap otherwise). Replaces the old per-lens
1075
+ // `preferredPhysicalDevice` request that mis-selected on some phones.
791
1076
  const capture = useCapture({
792
1077
  cameraPosition: 'back',
793
1078
  enableQualityChecks: false,
794
- preferredPhysicalDevice:
795
- lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
1079
+ lens,
796
1080
  });
1081
+
1082
+ // ── Lens chip availability ──────────────────────────────────────
1083
+ // v0.13.2 — real device capability from `useCapture` (which uses
1084
+ // `selectCaptureDevice`). True only when the device actually exposes
1085
+ // an ultra-wide reachable via a multi-cam zoom OR a standalone
1086
+ // ultra-wide device; false on wide-only hardware (chip hides).
1087
+ const has0_5x = capture.has0_5x;
797
1088
  const incremental = useIncrementalStitcher();
798
1089
  const visionCameraRef = useRef<VisionCamera | null>(null);
799
1090
  const arViewRef = useRef<ARCameraViewHandle | null>(null);
@@ -1349,6 +1640,47 @@ export function Camera(props: CameraProps): React.JSX.Element {
1349
1640
  setArPreference((prev) => !prev);
1350
1641
  }, []);
1351
1642
 
1643
+ // ── v0.13.0 — Flash control ─────────────────────────────────────
1644
+ //
1645
+ // `flashRequested` is what the host / built-in button asks for.
1646
+ // `effectiveFlash` is what we drive into vision-camera (non-AR). AR
1647
+ // mode forces 'off' (flash is hidden in AR; ARKit/ARCore own the
1648
+ // device) so vision-camera — which isn't the active camera in AR —
1649
+ // doesn't fight for it.
1650
+ //
1651
+ // v0.13.1 — the ACTIVE device's torch capability is the source of
1652
+ // truth. The ultra-wide (0.5×) lens has no flash/torch unit on most
1653
+ // phones, so vision-camera throws `flash-not-available` if we pass
1654
+ // flash="on" while it's selected. `capture.device.hasTorch` (from
1655
+ // vision-camera's device list) tells us definitively; we hide the
1656
+ // flash control and force 'off' when the device can't flash.
1657
+ // v0.13.2 — `capture.deviceHasTorch` reflects the MOUNTED device. In
1658
+ // multi-cam mode this is the multi-cam device (has a torch → flash
1659
+ // works on both 1× and 0.5× via zoom). In standalone-uw mode on 0.5×
1660
+ // the mounted device is the torchless ultra-wide → flash hides.
1661
+ const deviceHasTorch = capture.deviceHasTorch;
1662
+ const flashRequested: 'on' | 'off' = controlledFlash ?? internalFlash;
1663
+ const effectiveFlash: 'on' | 'off' =
1664
+ isAR || !deviceHasTorch ? 'off' : flashRequested;
1665
+ const toggleFlash = useCallback(() => {
1666
+ const next: 'on' | 'off' = flashRequested === 'on' ? 'off' : 'on';
1667
+ if (controlledFlash == null) setInternalFlash(next);
1668
+ onFlashChange?.(next);
1669
+ }, [flashRequested, controlledFlash, onFlashChange]);
1670
+
1671
+ // v0.13.1 — top-right control pills (flash + AR) stack vertically
1672
+ // UNDER the settings affordance. Anchor depends on what's above:
1673
+ // - headerTitle set → pills clear the CaptureHeader bar
1674
+ // (title row ≈ topInset + ~36; guidance pill adds ~28 when present)
1675
+ // - standalone gear → pills clear the 40px gear at topInset + 8
1676
+ // - neither → pills start where the gear would be
1677
+ const pillStackTop =
1678
+ headerTitle != null
1679
+ ? insets.top + (headerGuidance != null ? 72 : 40)
1680
+ : showSettingsButton
1681
+ ? insets.top + 8 + 44
1682
+ : insets.top + 8;
1683
+
1352
1684
  // ── JSX ─────────────────────────────────────────────────────────
1353
1685
 
1354
1686
  return (
@@ -1380,7 +1712,12 @@ export function Camera(props: CameraProps): React.JSX.Element {
1380
1712
  // works either way. Pattern matches AuditCaptureScreen.tsx
1381
1713
  // which has run on `video` (true) for months without issue.
1382
1714
  video
1383
- flash="off"
1715
+ flash={effectiveFlash}
1716
+ // v0.13.2 — in multi-cam mode the lens is switched via zoom
1717
+ // on a single mounted device (0.5× → ultra-wide end, 1× →
1718
+ // wide baseline). undefined in standalone/wide-only modes
1719
+ // (lens = device identity, no zoom).
1720
+ zoom={capture.deviceZoom}
1384
1721
  style={StyleSheet.absoluteFill}
1385
1722
  // F8 (FrameProcessor port) — host-supplied worklet runs on
1386
1723
  // the camera producer thread for every frame. Only wired
@@ -1416,6 +1753,13 @@ export function Camera(props: CameraProps): React.JSX.Element {
1416
1753
  recordingStartedAt={recordingStartedAt ?? undefined}
1417
1754
  />
1418
1755
 
1756
+ {/* v0.13.1 — the built-in pan-guidance overlays
1757
+ (IncrementalPanGuide drift marker + PanoramaGuidance speed
1758
+ pill) were removed from the public surface. They remain in
1759
+ the tree as internal-only components but <Camera> no longer
1760
+ renders them and the `panGuide` / `panoramaGuidance` props
1761
+ are gone. Re-wire here if a host need resurfaces. */}
1762
+
1419
1763
  {/*
1420
1764
  2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
1421
1765
  settings.debug. Mounts in <Camera> automatically; Layer-2
@@ -1459,12 +1803,34 @@ export function Camera(props: CameraProps): React.JSX.Element {
1459
1803
  topInset={insets.top}
1460
1804
  />
1461
1805
 
1462
- {/* Settings gear (top-right), gated on showSettingsButton. */}
1463
- {showSettingsButton && (
1464
- <SettingsButton
1465
- topInset={insets.top}
1466
- onPress={() => setSettingsModalVisible(true)}
1467
- />
1806
+ {/* v0.13.0 built-in CaptureHeader, gated on `headerTitle`.
1807
+ When the header is mounted, it absorbs the settings gear
1808
+ on its right side (avoids stacking with the standalone
1809
+ gear). Hosts that DON'T set `headerTitle` get the legacy
1810
+ standalone gear, still gated on `showSettingsButton`. */}
1811
+ {headerTitle != null ? (
1812
+ <View style={styles.headerWrap} pointerEvents="box-none">
1813
+ <CaptureHeader
1814
+ title={headerTitle}
1815
+ onBack={onHeaderBack}
1816
+ backLabel={headerBackLabel}
1817
+ guidance={headerGuidance}
1818
+ colors={headerColors}
1819
+ topInset={insets.top}
1820
+ onSettingsPress={
1821
+ showSettingsButton
1822
+ ? () => setSettingsModalVisible(true)
1823
+ : undefined
1824
+ }
1825
+ />
1826
+ </View>
1827
+ ) : (
1828
+ showSettingsButton && (
1829
+ <SettingsButton
1830
+ topInset={insets.top}
1831
+ onPress={() => setSettingsModalVisible(true)}
1832
+ />
1833
+ )
1468
1834
  )}
1469
1835
 
1470
1836
  {/*
@@ -1500,18 +1866,52 @@ export function Camera(props: CameraProps): React.JSX.Element {
1500
1866
  />
1501
1867
  )}
1502
1868
 
1869
+ {/* v0.13.0 — built-in capture-history thumbnail strip. Lives
1870
+ INSIDE the orientation-aware bottomArea container so it
1871
+ rides along to the home-indicator edge in landscape rather
1872
+ than sitting at a hard-coded `bottom: 160` mid-screen.
1873
+ Hidden during recording so the PanoramaBandOverlay above
1874
+ it has room without overlap. Strip is intrinsically
1875
+ horizontal; v0.13.1 will add orientation-aware rotation
1876
+ for the thumbnails + tablet "user-bottom" placement. */}
1877
+ {thumbnails != null && statusPhase !== 'recording' && (
1878
+ <CaptureThumbnailStrip
1879
+ items={thumbnails}
1880
+ minPhotos={thumbnailsMin}
1881
+ maxPhotos={thumbnailsMax}
1882
+ onItemPress={onThumbnailPress}
1883
+ // v0.13.1 — stack the idle strip vertically when the
1884
+ // home-indicator anchor is on a side edge (non-locked host
1885
+ // in landscape), matching PanoramaBandOverlay's `vertical`
1886
+ // so the strip rides the home-indicator edge instead of
1887
+ // running horizontally across the rotated screen.
1888
+ vertical={isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}
1889
+ // v0.13.1 — counter-rotate the thumbnail images so the
1890
+ // captured scene reads upright in portrait-locked landscape.
1891
+ contentRotation={contentRotation}
1892
+ />
1893
+ )}
1894
+
1503
1895
  {/* Shutter row. Horizontal row when home-indicator is on
1504
1896
  top/bottom (lens left / shutter center / AR right);
1505
1897
  vertical column when on left/right (slots stack along
1506
1898
  the narrow strip). Touch targets stay axis-aligned. */}
1507
1899
  <View style={bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}>
1900
+ {/* v0.13.1 — flash + AR moved to the top-right pill stack (see
1901
+ below). Left/right slots stay as flex spacers so the shutter
1902
+ + lens chip remain centred. */}
1508
1903
  <View style={styles.bottomBarLeft} />
1509
1904
  <View style={styles.bottomBarCenter}>
1510
- <LensChip
1511
- lens={lens}
1512
- onChange={handleLensChange}
1513
- has0_5x={has0_5x}
1514
- />
1905
+ {/* v0.13.2 — lens chooser hidden in AR-only mode (ARKit/ARCore
1906
+ can't use the ultra-wide, so there's nothing to choose). */}
1907
+ {!arOnly && (
1908
+ <LensChip
1909
+ lens={lens}
1910
+ onChange={handleLensChange}
1911
+ has0_5x={has0_5x}
1912
+ contentRotation={contentRotation}
1913
+ />
1914
+ )}
1515
1915
  <View style={styles.shutterWrap}>
1516
1916
  <CameraShutter
1517
1917
  onTap={handleTap}
@@ -1522,14 +1922,53 @@ export function Camera(props: CameraProps): React.JSX.Element {
1522
1922
  />
1523
1923
  </View>
1524
1924
  </View>
1525
- <View style={styles.bottomBarRight}>
1526
- {lens === '1x' && isARSupportedOnDevice && (
1527
- <ARToggle arEnabled={arPreference} onToggle={handleARToggle} />
1528
- )}
1529
- </View>
1925
+ <View style={styles.bottomBarRight} />
1530
1926
  </View>
1531
1927
  </View>
1532
1928
 
1929
+ {/* v0.13.1 — top-right control pill stack, anchored UNDER the
1930
+ settings affordance. Vertical column; pills match the AR
1931
+ toggle's shape. ORDER MATTERS: AR pill is FIRST (top) so it
1932
+ stays anchored when the flash pill below it shows/hides
1933
+ (flash is hidden in AR mode, and when the active device has no
1934
+ torch — e.g. the ultra-wide 0.5× lens). AR toggle shows only
1935
+ when the lens is 1× (ARKit/ARCore don't expose the ultra-wide)
1936
+ and the device supports AR. */}
1937
+ <View
1938
+ style={[styles.pillStack, { top: pillStackTop }]}
1939
+ pointerEvents="box-none"
1940
+ >
1941
+ {/* v0.13.2 — AR toggle only when BOTH sources are allowed
1942
+ (captureSources='both'); a single-source constraint has
1943
+ nothing to toggle. Still gated on 1× + device AR support. */}
1944
+ {arAllowed && nonArAllowed && lens === '1x' && isARSupportedOnDevice && (
1945
+ <ARToggle arEnabled={arPreference} onToggle={handleARToggle} contentRotation={contentRotation} />
1946
+ )}
1947
+ {showFlashButton && !isAR && deviceHasTorch && (
1948
+ <Pressable
1949
+ onPress={toggleFlash}
1950
+ accessibilityRole="button"
1951
+ accessibilityLabel={`Flash ${flashRequested === 'on' ? 'on' : 'off'}`}
1952
+ accessibilityState={{ selected: flashRequested === 'on' }}
1953
+ hitSlop={8}
1954
+ style={[
1955
+ pillStyles.pill,
1956
+ flashRequested === 'on' && pillStyles.pillActive,
1957
+ ]}
1958
+ >
1959
+ <Text
1960
+ style={[
1961
+ pillStyles.flashGlyph,
1962
+ flashRequested === 'on' && pillStyles.glyphActive,
1963
+ contentRotation,
1964
+ ]}
1965
+ >
1966
+
1967
+ </Text>
1968
+ </Pressable>
1969
+ )}
1970
+ </View>
1971
+
1533
1972
  {/* Settings modal (rendered always, visible-gated). */}
1534
1973
  <PanoramaSettingsModal
1535
1974
  visible={settingsModalVisible}
@@ -1550,6 +1989,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
1550
1989
  currentOrientation={drift.currentOrientation}
1551
1990
  onAcknowledge={() => setDriftModalDismissed(true)}
1552
1991
  />
1992
+
1993
+ {/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
1994
+ Visible when the host supplies `capturePreview`. When
1995
+ undefined the modal stays hidden (visible=false) so it
1996
+ doesn't intercept touches. Host is expected to clear
1997
+ `capturePreview` via `onCapturePreviewClose` on dismiss. */}
1998
+ <CapturePreview
1999
+ visible={capturePreview != null}
2000
+ imageUri={capturePreview?.imageUri ?? ''}
2001
+ imageWidth={capturePreview?.imageWidth}
2002
+ imageHeight={capturePreview?.imageHeight}
2003
+ title={capturePreview?.title}
2004
+ actions={capturePreviewActions}
2005
+ onClose={onCapturePreviewClose ?? noop}
2006
+ />
1553
2007
  </View>
1554
2008
  );
1555
2009
  }
@@ -1615,6 +2069,17 @@ function isSideEdge(edge: HomeIndicatorEdge): boolean {
1615
2069
  return edge === 'left' || edge === 'right';
1616
2070
  }
1617
2071
 
2072
+ // v0.13.1 — test-only exports of the pure orientation-decision
2073
+ // functions. `homeIndicatorEdge` + `isSideEdge` together produce the
2074
+ // `vertical` flag that drives PanoramaBandOverlay and
2075
+ // CaptureThumbnailStrip layout, so they carry the orientation contract.
2076
+ // Unit-tested via these handles (the lib's jest config is pure-TS and
2077
+ // can't mount <Camera>; see jest.config.js).
2078
+ /** @internal test-only — see `homeIndicatorEdge`. */
2079
+ export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
2080
+ /** @internal test-only — see `isSideEdge`. */
2081
+ export const _isSideEdgeForTests = isSideEdge;
2082
+
1618
2083
 
1619
2084
  /**
1620
2085
  * v0.12.0 — bottom-controls outer container positioning. Anchors
@@ -1731,6 +2196,8 @@ const styles = StyleSheet.create({
1731
2196
  },
1732
2197
  bottomBarLeft: {
1733
2198
  flex: 1,
2199
+ alignItems: 'flex-start',
2200
+ justifyContent: 'flex-end',
1734
2201
  },
1735
2202
  bottomBarCenter: {
1736
2203
  flex: 1,
@@ -1744,4 +2211,51 @@ const styles = StyleSheet.create({
1744
2211
  shutterWrap: {
1745
2212
  marginTop: 12,
1746
2213
  },
2214
+ headerWrap: {
2215
+ position: 'absolute',
2216
+ top: 0,
2217
+ left: 0,
2218
+ right: 0,
2219
+ },
2220
+ // v0.13.1 — `thumbnailStripWrap` removed. The strip now renders
2221
+ // inside the orientation-aware bottomArea container (alongside
2222
+ // PanoramaBandOverlay and the bottom bar) rather than as a
2223
+ // position-absolute overlay at hard-coded `bottom: 160`.
2224
+ //
2225
+ // v0.13.1 — top-right control pill stack (flash + AR). Absolute,
2226
+ // pinned to the right edge under the settings affordance; `top` is
2227
+ // set inline from `pillStackTop`. Column so the pills stack
2228
+ // vertically; gap keeps them from touching.
2229
+ pillStack: {
2230
+ position: 'absolute',
2231
+ right: 14,
2232
+ alignItems: 'flex-end',
2233
+ gap: 10,
2234
+ },
2235
+ });
2236
+
2237
+
2238
+ // v0.13.1 — shared pill style for the top-right control stack. The
2239
+ // flash pill matches the AR toggle's shape (same padding / radius /
2240
+ // background) so the two read as a set.
2241
+ const pillStyles = StyleSheet.create({
2242
+ pill: {
2243
+ paddingHorizontal: 14,
2244
+ paddingVertical: 8,
2245
+ borderRadius: 16,
2246
+ backgroundColor: 'rgba(0,0,0,0.45)',
2247
+ minWidth: 56,
2248
+ alignItems: 'center',
2249
+ justifyContent: 'center',
2250
+ },
2251
+ pillActive: {
2252
+ backgroundColor: '#ffd34d',
2253
+ },
2254
+ flashGlyph: {
2255
+ color: '#ffffff',
2256
+ fontSize: 18,
2257
+ },
2258
+ glyphActive: {
2259
+ color: '#1a1a1a',
2260
+ },
1747
2261
  });