react-native-image-stitcher 0.11.1 → 0.13.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 (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +28 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
  4. package/dist/camera/ARCameraView.d.ts +10 -0
  5. package/dist/camera/ARCameraView.js +1 -0
  6. package/dist/camera/Camera.d.ts +191 -0
  7. package/dist/camera/Camera.js +250 -9
  8. package/dist/camera/OrientationDriftModal.d.ts +83 -0
  9. package/dist/camera/OrientationDriftModal.js +159 -0
  10. package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
  11. package/dist/camera/PanoramaBandOverlay.js +106 -45
  12. package/dist/camera/PanoramaSettingsModal.js +15 -1
  13. package/dist/camera/ViewportCropOverlay.d.ts +35 -31
  14. package/dist/camera/ViewportCropOverlay.js +39 -30
  15. package/dist/camera/useDeviceOrientation.d.ts +18 -9
  16. package/dist/camera/useDeviceOrientation.js +18 -9
  17. package/dist/camera/useOrientationDrift.d.ts +104 -0
  18. package/dist/camera/useOrientationDrift.js +120 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +12 -1
  21. package/dist/stitching/incremental.d.ts +5 -3
  22. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
  23. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
  24. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +18 -1
  28. package/src/camera/Camera.tsx +639 -21
  29. package/src/camera/OrientationDriftModal.tsx +224 -0
  30. package/src/camera/PanoramaBandOverlay.tsx +135 -49
  31. package/src/camera/PanoramaSettingsModal.tsx +14 -0
  32. package/src/camera/ViewportCropOverlay.tsx +52 -30
  33. package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
  34. package/src/camera/useDeviceOrientation.ts +18 -9
  35. package/src/camera/useOrientationDrift.ts +172 -0
  36. package/src/index.ts +13 -0
  37. package/src/stitching/incremental.ts +5 -3
@@ -52,6 +52,7 @@ import {
52
52
  StyleSheet,
53
53
  Text,
54
54
  View,
55
+ useWindowDimensions,
55
56
  type StyleProp,
56
57
  type ViewStyle,
57
58
  } from 'react-native';
@@ -66,13 +67,21 @@ import { useARSession } from '../ar/useARSession';
66
67
  import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
67
68
  import { CameraShutter } from './CameraShutter';
68
69
  import { CameraView } from './CameraView';
70
+ import { CaptureHeader, type CaptureHeaderProps } from './CaptureHeader';
71
+ import { CapturePreview, type CapturePreviewAction } from './CapturePreview';
72
+ import {
73
+ CaptureThumbnailStrip,
74
+ type CaptureThumbnailItem,
75
+ } from './CaptureThumbnailStrip';
69
76
  import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
70
77
  import { CaptureDebugOverlay } from './CaptureDebugOverlay';
71
78
  import { CaptureMemoryPill } from './CaptureMemoryPill';
72
79
  import { CaptureKeyframePill } from './CaptureKeyframePill';
73
80
  import { CaptureOrientationPill } from './CaptureOrientationPill';
74
81
  import { CaptureStitchStatsToast, useStitchStatsToast } from './CaptureStitchStatsToast';
82
+ import { IncrementalPanGuide } from './IncrementalPanGuide';
75
83
  import { PanoramaBandOverlay } from './PanoramaBandOverlay';
84
+ import { PanoramaGuidance } from './PanoramaGuidance';
76
85
  import { type PanoramaSettings } from './PanoramaSettings';
77
86
  import { panoramaSettingsToNativeConfig } from './PanoramaSettingsBridge';
78
87
  import { PanoramaSettingsModal } from './PanoramaSettingsModal';
@@ -82,7 +91,9 @@ import {
82
91
  } from './buildPanoramaInitialSettings';
83
92
  import { isLowMemDevice } from './lowMemDevice';
84
93
  import { useCapture } from './useCapture';
85
- import { useDeviceOrientation } from './useDeviceOrientation';
94
+ import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
95
+ import { useOrientationDrift } from './useOrientationDrift';
96
+ import { OrientationDriftModal } from './OrientationDriftModal';
86
97
  import {
87
98
  getIncrementalNativeModule,
88
99
  incrementalStitcherIsAvailable,
@@ -293,6 +304,212 @@ export interface CameraProps {
293
304
  onFramesDropped?: (info: FramesDroppedInfo) => void;
294
305
  onError?: (err: CameraError) => void;
295
306
 
307
+ /**
308
+ * v0.12.0 — fires when the SDK auto-abandons an in-progress
309
+ * capture without producing output. `reason` is a string union
310
+ * so future reasons (network loss, low memory, etc.) can be added
311
+ * without breaking the callback signature.
312
+ *
313
+ * Currently the only reason in v0.12 is `'orientation-drift'`:
314
+ * the user rotated the device between Mode A (landscape + vertical
315
+ * pan) and Mode B (portrait + horizontal pan) mid-capture. The
316
+ * engine docstring at `incremental.ts:373-403` is explicit that
317
+ * cross-mode capture is "best-effort, not supported," so the SDK
318
+ * decisively cancels the capture (`incremental.cancel()`) and
319
+ * surfaces `OrientationDriftModal` to explain what happened.
320
+ *
321
+ * Hosts use this callback to clean up their own state (e.g., reset
322
+ * a wizard step, log telemetry, surface their own retry UX in
323
+ * addition to the SDK's built-in modal). No `onCapture` will fire
324
+ * for an abandoned capture.
325
+ */
326
+ onCaptureAbandoned?: (reason: 'orientation-drift') => void;
327
+
328
+ /**
329
+ * v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
330
+ *
331
+ * - **Uncontrolled** (omit `flash`): `<Camera>` owns the flash
332
+ * state internally. Tapping the built-in flash button toggles
333
+ * it on/off. `onFlashChange` (if supplied) fires for telemetry.
334
+ * - **Controlled** (supply `flash`): the parent owns the state.
335
+ * The built-in button still renders and fires `onFlashChange`
336
+ * on press, but it's a no-op unless the parent updates `flash`
337
+ * in response.
338
+ *
339
+ * Both shapes coexist with the v0.13 "flash button is on by default"
340
+ * built-in (see the bottom-left bar slot in the JSX). Hosts that
341
+ * want their own flash chrome can opt out via `showFlashButton={false}`
342
+ * and drive the underlying torch by controlling `flash` directly.
343
+ *
344
+ * ## AR-mode behaviour
345
+ *
346
+ * In AR mode (`defaultCaptureSource="ar"` or runtime-toggled),
347
+ * ARKit / ARCore own the `AVCaptureDevice` and don't expose the
348
+ * torch through vision-camera's pipeline. The built-in flash
349
+ * button renders as visibly disabled (a11y label "Flash unavailable
350
+ * in AR mode") and `flash` is forced to `'off'` regardless of
351
+ * controlled/uncontrolled state. Hosts that need flash should
352
+ * toggle to non-AR before enabling.
353
+ */
354
+ flash?: 'on' | 'off';
355
+
356
+ /**
357
+ * v0.13.0 — fires when the user taps the built-in flash button.
358
+ * In uncontrolled mode, the internal state has already flipped
359
+ * (single render delay). In controlled mode, the parent must
360
+ * update the `flash` prop in response or the visual toggle is
361
+ * a no-op. Useful in either mode for telemetry.
362
+ */
363
+ onFlashChange?: (next: 'on' | 'off') => void;
364
+
365
+ /**
366
+ * v0.13.0 — show the built-in flash button in the bottom-left
367
+ * slot. Defaults to `true`. Hosts that render their own flash
368
+ * chrome (and drive the underlying torch via the controlled
369
+ * `flash` prop) can opt out by setting this to `false`.
370
+ */
371
+ showFlashButton?: boolean;
372
+
373
+ /**
374
+ * v0.13.0 — show the built-in IncrementalPanGuide ("keep the
375
+ * arrow on the line" drift marker) while recording. Defaults
376
+ * to `true`. The guide is gyroscope-driven and only active
377
+ * during the recording phase (no idle sensor cost). Hosts that
378
+ * want their own pan-guide chrome can opt out via `false`.
379
+ */
380
+ panGuide?: boolean;
381
+
382
+ /**
383
+ * v0.13.0 — show the built-in PanoramaGuidance pan-speed pill
384
+ * ("Pan slowly" / "Slow down" / "Too fast") while recording.
385
+ * Defaults to `true`. Gyroscope-driven, only active during
386
+ * recording. Hosts that want their own speed chrome can opt
387
+ * out via `false`.
388
+ */
389
+ panoramaGuidance?: boolean;
390
+
391
+ /**
392
+ * v0.13.0 — built-in CaptureHeader title. When set, `<Camera>`
393
+ * renders a top-of-screen header showing this title (centred)
394
+ * with an optional back affordance + guidance subtitle + the
395
+ * existing settings gear absorbed into the header's right side.
396
+ *
397
+ * When `headerTitle` is undefined the header is not rendered
398
+ * (matches pre-v0.13 behaviour: top of preview is bare except
399
+ * for the standalone settings gear gated on `showSettingsButton`).
400
+ *
401
+ * Combine with `onHeaderBack`, `headerBackLabel`, `headerGuidance`,
402
+ * and `headerColors` to customise the rest of the header. Hosts
403
+ * that need richer header chrome can omit `headerTitle` and
404
+ * compose their own `<CaptureHeader>` above `<Camera>`.
405
+ */
406
+ headerTitle?: string;
407
+
408
+ /**
409
+ * v0.13.0 — header back-button callback. When supplied (and
410
+ * `headerTitle` is set), the header renders a back affordance
411
+ * on the left. Omitted ⇒ no back button (the title stays
412
+ * centred).
413
+ */
414
+ onHeaderBack?: () => void;
415
+
416
+ /**
417
+ * v0.13.0 — header back-button label. Defaults to "‹ Back".
418
+ * No effect unless `headerTitle` and `onHeaderBack` are both set.
419
+ */
420
+ headerBackLabel?: string;
421
+
422
+ /**
423
+ * v0.13.0 — optional second-line subtitle shown below the
424
+ * header title. E.g. "Photograph the promotional cola end cap."
425
+ * Renders nothing when undefined. No effect unless `headerTitle`
426
+ * is set.
427
+ */
428
+ headerGuidance?: string;
429
+
430
+ /**
431
+ * v0.13.0 — colour overrides for the built-in header. Defaults
432
+ * are white-on-black to stay legible over the camera preview.
433
+ * No effect unless `headerTitle` is set.
434
+ */
435
+ headerColors?: CaptureHeaderProps['colors'];
436
+
437
+ /**
438
+ * v0.13.0 — when provided (even as `[]`), `<Camera>` renders a
439
+ * built-in `CaptureThumbnailStrip` above the bottom controls
440
+ * showing the host's capture history. Each item is a plain
441
+ * `{ id, uri, width?, height? }` object; the strip handles
442
+ * aspect-ratio rendering, tap-to-preview, and the count line.
443
+ *
444
+ * Omit (`undefined`) to skip the strip entirely. Hosts using
445
+ * the strip independently (e.g. on a non-camera screen) can keep
446
+ * importing `CaptureThumbnailStrip` directly from the library —
447
+ * the prop here is the convenience wiring for in-`<Camera>` use.
448
+ *
449
+ * Captures emitted by `<Camera>`'s `onCapture` are NOT added to
450
+ * this array automatically — the host owns the canonical list
451
+ * (typically persisted to its own DB) and updates the prop in
452
+ * response. This matches the SDK's "Camera owns runtime state,
453
+ * host persists" pattern.
454
+ */
455
+ thumbnails?: CaptureThumbnailItem[];
456
+
457
+ /**
458
+ * v0.13.0 — minimum-photos hint for the count line. Renders
459
+ * "n / minPhotos min" with the success colour when reached,
460
+ * warning colour otherwise.
461
+ */
462
+ thumbnailsMin?: number;
463
+
464
+ /**
465
+ * v0.13.0 — maximum-photos hint for the count line. Renders
466
+ * "· maxPhotos max" suffix. No enforcement — the host decides
467
+ * what to do at the cap.
468
+ */
469
+ thumbnailsMax?: number;
470
+
471
+ /**
472
+ * v0.13.0 — tap handler for thumbnails. When set, replaces the
473
+ * strip's built-in tap-to-preview modal; the host shows its own
474
+ * preview UI (e.g. with delete / recapture buttons gated on
475
+ * sync state). Omit to use the built-in preview.
476
+ */
477
+ onThumbnailPress?: (item: CaptureThumbnailItem) => void;
478
+
479
+ /**
480
+ * v0.13.0 — when set, `<Camera>` renders a built-in `CapturePreview`
481
+ * modal as `visible`. Use this for post-stitch confirmation:
482
+ * after `onCapture` emits, the host stores the result and sets
483
+ * `capturePreview` to the new image, with `capturePreviewActions`
484
+ * = `[Discard, Save]` (or similar). Setting `undefined` hides
485
+ * the modal.
486
+ *
487
+ * Hosts using the modal for thumbnail tap-to-preview can leave
488
+ * this undefined and let the built-in strip's preview handle
489
+ * that case.
490
+ */
491
+ capturePreview?: {
492
+ imageUri: string;
493
+ imageWidth?: number;
494
+ imageHeight?: number;
495
+ title?: string;
496
+ };
497
+
498
+ /**
499
+ * v0.13.0 — action buttons rendered along the bottom of the
500
+ * `CapturePreview` modal. Empty array (or undefined) renders
501
+ * no buttons, only the close affordance.
502
+ */
503
+ capturePreviewActions?: CapturePreviewAction[];
504
+
505
+ /**
506
+ * v0.13.0 — fires when the user dismisses the `capturePreview`
507
+ * modal (tap close, backdrop tap, hardware back on Android).
508
+ * The host is expected to clear the `capturePreview` prop in
509
+ * response.
510
+ */
511
+ onCapturePreviewClose?: () => void;
512
+
296
513
  /**
297
514
  * Optional host-supplied vision-camera frame processor.
298
515
  *
@@ -656,17 +873,49 @@ export function Camera(props: CameraProps): React.JSX.Element {
656
873
  onLensChange,
657
874
  onFramesDropped,
658
875
  onError,
876
+ onCaptureAbandoned,
877
+ flash: controlledFlash,
878
+ onFlashChange,
879
+ showFlashButton = true,
880
+ panGuide = true,
881
+ panoramaGuidance = true,
882
+ headerTitle,
883
+ onHeaderBack,
884
+ headerBackLabel,
885
+ headerGuidance,
886
+ headerColors,
887
+ thumbnails,
888
+ thumbnailsMin,
889
+ thumbnailsMax,
890
+ onThumbnailPress,
891
+ capturePreview,
892
+ capturePreviewActions,
893
+ onCapturePreviewClose,
659
894
  frameProcessor: hostFrameProcessor,
660
895
  engine = 'batch-keyframe',
661
896
  } = props;
662
897
 
663
898
  const insets = useSafeAreaInsets();
899
+ // v0.12.0 — JS-layout orientation independent of device-physical.
900
+ // `useWindowDimensions().width > height` tells us if the OS
901
+ // rotated the framebuffer (only happens for non-locked hosts in
902
+ // device-landscape). Combined with `useDeviceOrientation()` to
903
+ // pick the JS edge corresponding to the home-indicator side of
904
+ // the device — see `homeIndicatorEdge` below.
905
+ const jsWindow = useWindowDimensions();
906
+ const jsLandscape = jsWindow.width > jsWindow.height;
664
907
 
665
908
  // ── State ───────────────────────────────────────────────────────
666
909
  const [arPreference, setArPreference] = useState(
667
910
  defaultCaptureSource === 'ar',
668
911
  );
669
912
  const [lens, setLens] = useState<CameraLens>(defaultLens);
913
+ // v0.13.0 — flash state. Controlled by `controlledFlash` when the
914
+ // host supplies the `flash` prop; otherwise owned internally and
915
+ // toggled by the built-in flash button. `effectiveFlash` below
916
+ // also forces 'off' in AR mode (ARKit / ARCore own the device's
917
+ // torch and don't surface it through vision-camera's pipeline).
918
+ const [internalFlash, setInternalFlash] = useState<'on' | 'off'>('off');
670
919
  const [settings, setSettings] = useState<PanoramaSettings>(() =>
671
920
  buildPanoramaInitialSettings(
672
921
  extractPanoramaOverrides(props),
@@ -857,6 +1106,64 @@ export function Camera(props: CameraProps): React.JSX.Element {
857
1106
  // eslint-disable-next-line react-hooks/exhaustive-deps
858
1107
  useEffect(() => () => { fpDriver.stop(); }, []);
859
1108
 
1109
+ // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
1110
+ //
1111
+ // The incremental engine supports both portrait (Mode B, horizontal
1112
+ // pan) and landscape (Mode A, vertical pan) capture as first-class,
1113
+ // but the docstring at `incremental.ts:373-403` is explicit that
1114
+ // mixing them mid-capture is "best-effort, not supported" — the
1115
+ // output rotation becomes ambiguous and the stitched panorama is
1116
+ // malformed. v0.12 protects against this by snapshotting the
1117
+ // orientation at `start()` and auto-cancelling the capture the
1118
+ // instant the user rotates to a different orientation mid-flight.
1119
+ //
1120
+ // The modal is informational only — by the time it renders, the
1121
+ // capture is already stopped. No Continue/Resume affordance per
1122
+ // the engine spec.
1123
+ const drift = useOrientationDrift(statusPhase === 'recording');
1124
+ const [driftModalDismissed, setDriftModalDismissed] = useState(false);
1125
+ // Reset the dismissed flag when a new capture starts (or any non-
1126
+ // recording state) so the next drift event surfaces a fresh modal.
1127
+ useEffect(() => {
1128
+ if (statusPhase !== 'recording') setDriftModalDismissed(false);
1129
+ }, [statusPhase]);
1130
+
1131
+ useEffect(() => {
1132
+ if (!drift.drifted || statusPhase !== 'recording') return;
1133
+ // Auto-abandon the in-flight capture. Order matches handleHoldEnd's
1134
+ // "stitch" path but skips finalize:
1135
+ // 1. Stop pumping frames so no new keyframes arrive mid-cancel.
1136
+ // 2. Tell the native engine to drop accumulated state
1137
+ // (`incremental.cancel()`).
1138
+ // 3. Reset statusPhase back to idle.
1139
+ // 4. Notify the host via `onCaptureAbandoned`.
1140
+ //
1141
+ // Wrapped in an IIFE because useEffect callbacks can't be async
1142
+ // directly. Errors from `incremental.cancel()` are caught + sent
1143
+ // through `onError` — abandonment must succeed even if the engine
1144
+ // is in a weird state.
1145
+ void (async () => {
1146
+ fpDriver.stop();
1147
+ try {
1148
+ await incremental.cancel();
1149
+ } catch (err) {
1150
+ const message = err instanceof Error ? err.message : String(err);
1151
+ onError?.(new CameraError(
1152
+ 'PANORAMA_FINALIZE_FAILED',
1153
+ `cancel after orientation drift failed: ${message}`,
1154
+ err,
1155
+ ));
1156
+ } finally {
1157
+ setStatusPhase('idle');
1158
+ setRecordingStartedAt(null);
1159
+ onCaptureAbandoned?.('orientation-drift');
1160
+ }
1161
+ })();
1162
+ // Deps: re-run whenever drift latches OR recording state changes.
1163
+ // Other deps are stable refs / setters.
1164
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1165
+ }, [drift.drifted, statusPhase]);
1166
+
860
1167
  // v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
861
1168
  //
862
1169
  // - Host supplied? → use host's processor. The host's worklet
@@ -991,7 +1298,14 @@ export function Camera(props: CameraProps): React.JSX.Element {
991
1298
  // ARCameraView writes to its own tmp location; relocate to
992
1299
  // photoOutputPath via the native FileBridge so both branches
993
1300
  // return paths under the same dir.
994
- const photo = await arViewRef.current.takePhoto({ quality: 90 });
1301
+ // v0.12.0 pass deviceOrientation so the AR takePhoto's
1302
+ // native CIImage rotation matches the user's view. Pre-
1303
+ // v0.12 the native side hardcoded portrait, so landscape
1304
+ // photos came out sideways.
1305
+ const photo = await arViewRef.current.takePhoto({
1306
+ quality: 90,
1307
+ orientation: deviceOrientation,
1308
+ });
995
1309
  try {
996
1310
  await moveFile(photo.path, photoOutputPath);
997
1311
  } catch (moveErr) {
@@ -1251,6 +1565,22 @@ export function Camera(props: CameraProps): React.JSX.Element {
1251
1565
  setArPreference((prev) => !prev);
1252
1566
  }, []);
1253
1567
 
1568
+ // ── v0.13.0 — Flash control ─────────────────────────────────────
1569
+ //
1570
+ // `flashRequested` is what the host / built-in button asks for.
1571
+ // `effectiveFlash` is what we actually drive into vision-camera —
1572
+ // AR mode forces 'off' because ARKit / ARCore own AVCaptureDevice
1573
+ // and the torch isn't exposed. This way the button's visual state
1574
+ // (a11y, styling) tracks `flashRequested` while the underlying
1575
+ // camera always sees the correct value.
1576
+ const flashRequested: 'on' | 'off' = controlledFlash ?? internalFlash;
1577
+ const effectiveFlash: 'on' | 'off' = isAR ? 'off' : flashRequested;
1578
+ const toggleFlash = useCallback(() => {
1579
+ const next: 'on' | 'off' = flashRequested === 'on' ? 'off' : 'on';
1580
+ if (controlledFlash == null) setInternalFlash(next);
1581
+ onFlashChange?.(next);
1582
+ }, [flashRequested, controlledFlash, onFlashChange]);
1583
+
1254
1584
  // ── JSX ─────────────────────────────────────────────────────────
1255
1585
 
1256
1586
  return (
@@ -1282,7 +1612,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1282
1612
  // works either way. Pattern matches AuditCaptureScreen.tsx
1283
1613
  // which has run on `video` (true) for months without issue.
1284
1614
  video
1285
- flash="off"
1615
+ flash={effectiveFlash}
1286
1616
  style={StyleSheet.absoluteFill}
1287
1617
  // F8 (FrameProcessor port) — host-supplied worklet runs on
1288
1618
  // the camera producer thread for every frame. Only wired
@@ -1318,6 +1648,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
1318
1648
  recordingStartedAt={recordingStartedAt ?? undefined}
1319
1649
  />
1320
1650
 
1651
+ {/* v0.13.0 — built-in pan guidance overlays. Both sit on top
1652
+ of the camera preview but under the controls. Each is
1653
+ gyroscope-driven and only subscribes while `active` is
1654
+ true — flipping `active` false on capture-end tears the
1655
+ subscription down so the sensor isn't running idle. Hosts
1656
+ can opt out per overlay via the `panGuide` / `panoramaGuidance`
1657
+ boolean props (both default true). */}
1658
+ {panGuide && (
1659
+ <IncrementalPanGuide active={statusPhase === 'recording'} />
1660
+ )}
1661
+ {panoramaGuidance && (
1662
+ <PanoramaGuidance active={statusPhase === 'recording'} />
1663
+ )}
1664
+
1321
1665
  {/*
1322
1666
  2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
1323
1667
  settings.debug. Mounts in <Camera> automatically; Layer-2
@@ -1361,39 +1705,111 @@ export function Camera(props: CameraProps): React.JSX.Element {
1361
1705
  topInset={insets.top}
1362
1706
  />
1363
1707
 
1364
- {/* Settings gear (top-right), gated on showSettingsButton. */}
1365
- {showSettingsButton && (
1366
- <SettingsButton
1367
- topInset={insets.top}
1368
- onPress={() => setSettingsModalVisible(true)}
1369
- />
1708
+ {/* v0.13.0 built-in CaptureHeader, gated on `headerTitle`.
1709
+ When the header is mounted, it absorbs the settings gear
1710
+ on its right side (avoids stacking with the standalone
1711
+ gear). Hosts that DON'T set `headerTitle` get the legacy
1712
+ standalone gear, still gated on `showSettingsButton`. */}
1713
+ {headerTitle != null ? (
1714
+ <View style={styles.headerWrap} pointerEvents="box-none">
1715
+ <CaptureHeader
1716
+ title={headerTitle}
1717
+ onBack={onHeaderBack}
1718
+ backLabel={headerBackLabel}
1719
+ guidance={headerGuidance}
1720
+ colors={headerColors}
1721
+ topInset={insets.top}
1722
+ onSettingsPress={
1723
+ showSettingsButton
1724
+ ? () => setSettingsModalVisible(true)
1725
+ : undefined
1726
+ }
1727
+ />
1728
+ </View>
1729
+ ) : (
1730
+ showSettingsButton && (
1731
+ <SettingsButton
1732
+ topInset={insets.top}
1733
+ onPress={() => setSettingsModalVisible(true)}
1734
+ />
1735
+ )
1736
+ )}
1737
+
1738
+ {/* v0.13.0 — built-in capture-history thumbnail strip. Renders
1739
+ when the host supplies a `thumbnails` array (even empty),
1740
+ hidden during recording so it doesn't overlap the band
1741
+ overlay. Sits above the bottom controls in JS-bottom
1742
+ coordinates; landscape/non-locked layouts get the strip in
1743
+ the same place (no orientation-aware repositioning for now —
1744
+ the strip is intrinsically horizontal). */}
1745
+ {thumbnails != null && statusPhase !== 'recording' && (
1746
+ <View style={styles.thumbnailStripWrap} pointerEvents="box-none">
1747
+ <CaptureThumbnailStrip
1748
+ items={thumbnails}
1749
+ minPhotos={thumbnailsMin}
1750
+ maxPhotos={thumbnailsMax}
1751
+ onItemPress={onThumbnailPress}
1752
+ />
1753
+ </View>
1370
1754
  )}
1371
1755
 
1372
1756
  {/*
1373
- Bottom area: stacks the live-frame band ABOVE the shutter row
1374
- so the band is tethered to the shutter on the viewport side
1375
- (the operator's eye is drawn from the camera preview, down
1376
- the band, into the shutter a single continuous reading
1377
- path). With the SDK's orientation lock holding the UI in
1378
- portrait, this stack works the same regardless of how the
1379
- device is physically held.
1757
+ v0.12.0 Orientation-aware bottom controls anchored to the
1758
+ physical home-indicator edge. The shutter follows the home-
1759
+ indicator regardless of host portrait-lock state:
1760
+ - locked + any device → JS-bottom (locked
1761
+ framebuffer maps device-bottom to JS-bottom always)
1762
+ - non-locked + device-portrait → JS-bottom
1763
+ - non-locked + device-landscape-L → JS-right
1764
+ - non-locked + device-landscape-R → JS-left
1765
+ Computed in `homeIndicatorEdge` which combines `jsLandscape`
1766
+ (from window dims) with `deviceOrientation` (sensor).
1380
1767
  */}
1381
1768
  <View
1382
1769
  pointerEvents="box-none"
1383
- style={[styles.bottomArea, { paddingBottom: insets.bottom + 12 }]}
1770
+ style={bottomAreaStyleForEdge(
1771
+ homeIndicatorEdge(jsLandscape, deviceOrientation),
1772
+ insets.bottom + 12,
1773
+ insets.top + 12,
1774
+ )}
1384
1775
  >
1385
- {/* Live-frame band — only visible while recording. */}
1776
+ {/* Live-frame band — only visible while recording. `vertical`
1777
+ is true when the home-indicator anchor is on a side edge
1778
+ (left or right), in which case the band is a vertical
1779
+ column. Otherwise it's a horizontal strip. */}
1386
1780
  {statusPhase === 'recording' && (
1387
1781
  <PanoramaBandOverlay
1388
1782
  state={incrementalState}
1389
1783
  frameUris={batchKeyframeThumbnails}
1390
1784
  captureOrientation={deviceOrientation}
1785
+ vertical={isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}
1391
1786
  />
1392
1787
  )}
1393
1788
 
1394
- {/* Shutter row: lens chip (left), shutter (centre), AR toggle (right). */}
1395
- <View style={styles.bottomBar}>
1396
- <View style={styles.bottomBarLeft} />
1789
+ {/* Shutter row. Horizontal row when home-indicator is on
1790
+ top/bottom (lens left / shutter center / AR right);
1791
+ vertical column when on left/right (slots stack along
1792
+ the narrow strip). Touch targets stay axis-aligned. */}
1793
+ <View style={bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}>
1794
+ <View style={styles.bottomBarLeft}>
1795
+ {showFlashButton && (
1796
+ <Pressable
1797
+ onPress={isAR ? undefined : toggleFlash}
1798
+ accessibilityRole="button"
1799
+ accessibilityLabel={isAR ? 'Flash unavailable in AR mode' : `Flash ${flashRequested === 'on' ? 'on' : 'off'}`}
1800
+ accessibilityState={{ selected: flashRequested === 'on', disabled: isAR }}
1801
+ disabled={isAR}
1802
+ hitSlop={8}
1803
+ style={[
1804
+ styles.flashButton,
1805
+ flashRequested === 'on' && !isAR && styles.flashButtonActive,
1806
+ isAR && styles.flashButtonDisabled,
1807
+ ]}
1808
+ >
1809
+ <Text style={styles.flashIcon}>⚡</Text>
1810
+ </Pressable>
1811
+ )}
1812
+ </View>
1397
1813
  <View style={styles.bottomBarCenter}>
1398
1814
  <LensChip
1399
1815
  lens={lens}
@@ -1425,6 +1841,34 @@ export function Camera(props: CameraProps): React.JSX.Element {
1425
1841
  onChange={setSettings}
1426
1842
  onClose={() => setSettingsModalVisible(false)}
1427
1843
  />
1844
+
1845
+ {/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
1846
+ auto-abandoned the capture (the useEffect above stops the
1847
+ engine + transitions to idle + fires onCaptureAbandoned).
1848
+ Modal exists purely to explain WHY the capture was
1849
+ cancelled. Single OK button (no Continue) per the engine
1850
+ spec on cross-mode capture being best-effort, not supported. */}
1851
+ <OrientationDriftModal
1852
+ visible={drift.drifted && !driftModalDismissed}
1853
+ captureOrientation={drift.captureOrientation}
1854
+ currentOrientation={drift.currentOrientation}
1855
+ onAcknowledge={() => setDriftModalDismissed(true)}
1856
+ />
1857
+
1858
+ {/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
1859
+ Visible when the host supplies `capturePreview`. When
1860
+ undefined the modal stays hidden (visible=false) so it
1861
+ doesn't intercept touches. Host is expected to clear
1862
+ `capturePreview` via `onCapturePreviewClose` on dismiss. */}
1863
+ <CapturePreview
1864
+ visible={capturePreview != null}
1865
+ imageUri={capturePreview?.imageUri ?? ''}
1866
+ imageWidth={capturePreview?.imageWidth}
1867
+ imageHeight={capturePreview?.imageHeight}
1868
+ title={capturePreview?.title}
1869
+ actions={capturePreviewActions}
1870
+ onClose={onCapturePreviewClose ?? noop}
1871
+ />
1428
1872
  </View>
1429
1873
  );
1430
1874
  }
@@ -1435,6 +1879,148 @@ function noop(): void {
1435
1879
  }
1436
1880
 
1437
1881
 
1882
+ /**
1883
+ * v0.12.0 — JS edge corresponding to the physical home-indicator
1884
+ * side of the device. This is where the shutter + controls anchor
1885
+ * to so they're always within thumb reach of the user's grip
1886
+ * (matching iOS Camera's behaviour).
1887
+ *
1888
+ * Combines two signals:
1889
+ * - `jsLandscape`: whether the OS rotated the framebuffer. True
1890
+ * only for non-locked hosts in device-landscape.
1891
+ * - `deviceOrient`: physical device orientation from the sensor.
1892
+ *
1893
+ * Truth table:
1894
+ * | jsLandscape | deviceOrient | edge |
1895
+ * |--- |--- |--- |
1896
+ * | false | any | bottom | (portrait JS coords —
1897
+ * | | | | device-bottom = JS-bottom
1898
+ * | | | | in both locked and
1899
+ * | | | | non-locked-portrait)
1900
+ * | true | landscape-left | right | (screen rotated, home
1901
+ * | | | | indicator on user-right)
1902
+ * | true | landscape-right | left | (mirror)
1903
+ *
1904
+ * Caveats:
1905
+ * - Non-locked + upside-down doesn't surface JS-top here because
1906
+ * upside-down doesn't change window dimensions; we can't
1907
+ * distinguish locked-portrait-with-device-flipped from
1908
+ * non-locked-portrait-with-screen-flipped-180°. Defaults to
1909
+ * JS-bottom which matches the more common locked case. Add
1910
+ * handling here when a host needs upside-down support.
1911
+ * - jsLandscape=true with non-landscape device shouldn't happen
1912
+ * in steady state — only during a transition mid-rotation.
1913
+ * Falls through to 'right' as a defensive default.
1914
+ */
1915
+ type HomeIndicatorEdge = 'bottom' | 'top' | 'left' | 'right';
1916
+
1917
+ function homeIndicatorEdge(
1918
+ jsLandscape: boolean,
1919
+ deviceOrient: DeviceOrientation,
1920
+ ): HomeIndicatorEdge {
1921
+ if (!jsLandscape) return 'bottom';
1922
+ if (deviceOrient === 'landscape-left') return 'right';
1923
+ if (deviceOrient === 'landscape-right') return 'left';
1924
+ return 'right';
1925
+ }
1926
+
1927
+
1928
+ /**
1929
+ * v0.12.0 — true when the anchor edge is on a side (left/right), so
1930
+ * the band + shutter row need to be vertical strips. Top/bottom
1931
+ * anchors yield horizontal strips.
1932
+ */
1933
+ function isSideEdge(edge: HomeIndicatorEdge): boolean {
1934
+ return edge === 'left' || edge === 'right';
1935
+ }
1936
+
1937
+
1938
+ /**
1939
+ * v0.12.0 — bottom-controls outer container positioning. Anchors
1940
+ * to the home-indicator JS edge with the appropriate flex direction
1941
+ * so the band sits on the viewport side of the shutter (toward the
1942
+ * camera preview centre).
1943
+ */
1944
+ function bottomAreaStyleForEdge(
1945
+ edge: HomeIndicatorEdge,
1946
+ bottomInsetPx: number,
1947
+ topInsetPx: number,
1948
+ ): ViewStyle {
1949
+ switch (edge) {
1950
+ case 'bottom':
1951
+ // Band above shutter row, both at JS-bottom. JSX order
1952
+ // [band, shutter] + flexDirection 'column' = band at top of
1953
+ // stack (closer to screen centre), shutter at JS-bottom.
1954
+ return {
1955
+ position: 'absolute',
1956
+ left: 0,
1957
+ right: 0,
1958
+ bottom: 0,
1959
+ flexDirection: 'column',
1960
+ alignItems: 'stretch',
1961
+ paddingBottom: bottomInsetPx,
1962
+ };
1963
+ case 'top':
1964
+ // Mirror of bottom. column-reverse so JSX [band, shutter]
1965
+ // renders [shutter, band] in JS, shutter at JS-top, band
1966
+ // below it (toward screen centre).
1967
+ return {
1968
+ position: 'absolute',
1969
+ left: 0,
1970
+ right: 0,
1971
+ top: 0,
1972
+ flexDirection: 'column-reverse',
1973
+ alignItems: 'stretch',
1974
+ paddingTop: topInsetPx,
1975
+ };
1976
+ case 'right':
1977
+ // Band to the left of shutter column, both at JS-right.
1978
+ // flexDirection 'row' + JSX [band, shutter] = band at JS-left
1979
+ // of container (screen centre side), shutter at JS-right.
1980
+ return {
1981
+ position: 'absolute',
1982
+ top: 0,
1983
+ bottom: 0,
1984
+ right: 0,
1985
+ flexDirection: 'row',
1986
+ alignItems: 'stretch',
1987
+ paddingRight: 12,
1988
+ };
1989
+ case 'left':
1990
+ // Mirror of right. row-reverse so JSX [band, shutter] gives
1991
+ // band at JS-right (screen centre side), shutter at JS-left.
1992
+ return {
1993
+ position: 'absolute',
1994
+ top: 0,
1995
+ bottom: 0,
1996
+ left: 0,
1997
+ flexDirection: 'row-reverse',
1998
+ alignItems: 'stretch',
1999
+ paddingLeft: 12,
2000
+ };
2001
+ }
2002
+ }
2003
+
2004
+
2005
+ /**
2006
+ * v0.12.0 — inner shutter-row flex direction. Horizontal row for
2007
+ * top/bottom anchors; vertical column for left/right anchors so
2008
+ * the three slots (lens / shutter / AR) stack along the narrow
2009
+ * side strip. Buttons don't rotate — touch targets and text
2010
+ * orient correctly via either (a) un-rotated framebuffer under
2011
+ * portrait-lock or (b) OS-rotated framebuffer under non-locked.
2012
+ */
2013
+ function bottomBarStyleForEdge(edge: HomeIndicatorEdge): ViewStyle {
2014
+ const vertical = isSideEdge(edge);
2015
+ return {
2016
+ flexDirection: vertical ? 'column' : 'row',
2017
+ paddingHorizontal: vertical ? 0 : 18,
2018
+ paddingVertical: vertical ? 18 : 0,
2019
+ alignItems: 'center',
2020
+ };
2021
+ }
2022
+
2023
+
1438
2024
  const styles = StyleSheet.create({
1439
2025
  container: {
1440
2026
  flex: 1,
@@ -1464,6 +2050,8 @@ const styles = StyleSheet.create({
1464
2050
  },
1465
2051
  bottomBarLeft: {
1466
2052
  flex: 1,
2053
+ alignItems: 'flex-start',
2054
+ justifyContent: 'flex-end',
1467
2055
  },
1468
2056
  bottomBarCenter: {
1469
2057
  flex: 1,
@@ -1477,4 +2065,34 @@ const styles = StyleSheet.create({
1477
2065
  shutterWrap: {
1478
2066
  marginTop: 12,
1479
2067
  },
2068
+ headerWrap: {
2069
+ position: 'absolute',
2070
+ top: 0,
2071
+ left: 0,
2072
+ right: 0,
2073
+ },
2074
+ thumbnailStripWrap: {
2075
+ position: 'absolute',
2076
+ left: 0,
2077
+ right: 0,
2078
+ bottom: 160,
2079
+ },
2080
+ flashButton: {
2081
+ width: 44,
2082
+ height: 44,
2083
+ borderRadius: 22,
2084
+ alignItems: 'center',
2085
+ justifyContent: 'center',
2086
+ backgroundColor: 'rgba(0,0,0,0.45)',
2087
+ },
2088
+ flashButtonActive: {
2089
+ backgroundColor: '#ffd34d',
2090
+ },
2091
+ flashButtonDisabled: {
2092
+ opacity: 0.35,
2093
+ },
2094
+ flashIcon: {
2095
+ fontSize: 20,
2096
+ color: '#ffffff',
2097
+ },
1480
2098
  });