react-native-image-stitcher 0.15.2 → 0.16.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 (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -0,0 +1,188 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * RotateToLandscapePrompt — full-screen, non-interactive overlay shown
4
+ * while a Mode-A (landscape, top→bottom pan) capture is waiting for the
5
+ * user to physically rotate the device to landscape.
6
+ *
7
+ * ┌──────────────────────────────────────────────────────────┐
8
+ * │ (faint scrim over preview) │
9
+ * │ │
10
+ * │ ┌───────────────┐ │
11
+ * │ │ ⟳ phone │ ← code-drawn │
12
+ * │ │ line-art │ (240px square) │
13
+ * │ └───────────────┘ │
14
+ * │ │
15
+ * │ ● Rotate to landscape ← caption pill │
16
+ * └──────────────────────────────────────────────────────────┘
17
+ *
18
+ * Item 2 of the first-time-user guidance set. It is the first thing a
19
+ * user sees after starting a landscape-only capture in portrait — the
20
+ * GIF demonstrates the rotation gesture and the pill names the goal.
21
+ *
22
+ * ## Pure-presentational
23
+ *
24
+ * The component owns no orientation/eligibility logic: the host
25
+ * (`<Camera>`) decides *when* a Mode-A capture is blocked on rotation
26
+ * and drives `visible`. When `visible` is false we render `null` so
27
+ * the host can mount us unconditionally without layout churn — mirrors
28
+ * `CaptureStatusOverlay`'s `idle` → `null` contract.
29
+ *
30
+ * ## Why the WHOLE prompt counter-rotates
31
+ *
32
+ * The host app is typically portrait-locked, so when the user tilts to
33
+ * landscape the OS does NOT rotate the framebuffer and JS-"up" stays at
34
+ * the device's side edge. We counter-rotate the entire prompt (graphic
35
+ * + caption) via `useContentRotation()` — the same hook the bottom
36
+ * controls use — so it reads upright relative to actual gravity.
37
+ *
38
+ * This matters for BOTH children, not just the text:
39
+ * - the **caption** is text and must read left-to-right;
40
+ * - the **graphic is now directional** — its camera dot starts on one
41
+ * edge and rotates to another to demonstrate the gesture, so an
42
+ * un-rotated graphic in a landscape hold reads 90° off (the dot
43
+ * appears to start "down" and travel "left" instead of "left" →
44
+ * "top"). It is therefore counter-rotated with the caption.
45
+ * - the column **layout** (caption below the graphic) also only reads
46
+ * as a physical column once the wrapper is upright — otherwise
47
+ * "below" lands at the physical side edge.
48
+ *
49
+ * (An earlier version rotated only the caption, back when the graphic
50
+ * was a symmetric spinner with no start/end direction.) In a portrait
51
+ * hold the hook returns 0° so this is a no-op; once the device reaches
52
+ * the target orientation the host flips `visible` to false anyway, but
53
+ * the counter-rotation keeps everything legible during the in-between
54
+ * tilt.
55
+ *
56
+ * ## Accessibility
57
+ *
58
+ * `accessibilityRole='alert'` + `accessibilityLiveRegion='polite'` so
59
+ * VoiceOver / TalkBack announce the rotation instruction when the
60
+ * prompt appears (and re-announce if the copy changes), matching the
61
+ * pattern in `PanoramaGuidance`.
62
+ */
63
+
64
+ import React from 'react';
65
+ import {
66
+ StyleSheet,
67
+ Text,
68
+ View,
69
+ type StyleProp,
70
+ type ViewStyle,
71
+ } from 'react-native';
72
+
73
+ import { DEFAULT_GUIDANCE_COPY } from './cameraGuidanceCopy';
74
+ import { RotatePhoneGraphic } from './guidanceGraphics';
75
+ import { GUIDANCE_PILL, GUIDANCE_TOKENS } from './guidanceTokens';
76
+ import { useContentRotation } from './useContentRotation';
77
+
78
+
79
+ export interface RotateToLandscapePromptProps {
80
+ /**
81
+ * Show / hide. Driven by the host while a Mode-A capture is blocked
82
+ * on the user rotating to landscape. `false` renders nothing.
83
+ */
84
+ visible: boolean;
85
+ /**
86
+ * Caption copy. Defaults to `DEFAULT_GUIDANCE_COPY.rotateToLandscape`
87
+ * ("Rotate to landscape"). Hosts localise via the `guidanceCopy`
88
+ * `<Camera>` prop and pass the resolved string here. When `target` is
89
+ * `'portrait'`, pass the rotate-to-portrait copy.
90
+ */
91
+ copy?: string;
92
+ /**
93
+ * Orientation to rotate TO: `'landscape'` (default, panMode `'vertical'`)
94
+ * or `'portrait'` (panMode `'horizontal'`). Drives the rotating-phone
95
+ * graphic's direction.
96
+ */
97
+ target?: 'landscape' | 'portrait';
98
+ /** Outer style passthrough (applied to the absolute-fill root). */
99
+ style?: StyleProp<ViewStyle>;
100
+ }
101
+
102
+
103
+ export function RotateToLandscapePrompt({
104
+ visible,
105
+ copy = DEFAULT_GUIDANCE_COPY.rotateToLandscape,
106
+ target = 'landscape',
107
+ style,
108
+ }: RotateToLandscapePromptProps): React.JSX.Element | null {
109
+ // Counter-rotate the WHOLE prompt so it reads upright relative to
110
+ // gravity while the device is mid-tilt (locked-portrait hosts) — see
111
+ // the file header. Called before the early return so the hook order
112
+ // stays stable across visible toggles.
113
+ const contentRotation = useContentRotation();
114
+
115
+ if (!visible) return null;
116
+
117
+ return (
118
+ <View
119
+ // pointerEvents=none — the prompt is read-only and must never
120
+ // steal taps from the camera / shutter beneath it.
121
+ pointerEvents="none"
122
+ style={[StyleSheet.absoluteFill, styles.root, style]}
123
+ accessibilityRole="alert"
124
+ accessibilityLiveRegion="polite"
125
+ >
126
+ {/* Graphic + caption share ONE counter-rotated column so both the
127
+ directional graphic and the "caption below" layout stay correct
128
+ relative to gravity (see header). In portrait the rotation is a
129
+ no-op. */}
130
+ <View style={[styles.content, contentRotation]}>
131
+ {/* Code-drawn rotating-phone graphic (decorative — the caption
132
+ carries the instruction for assistive tech). */}
133
+ <RotatePhoneGraphic playing={visible} target={target} />
134
+
135
+ <View style={styles.pill}>
136
+ <View style={styles.dot} />
137
+ <Text style={styles.caption} numberOfLines={1}>
138
+ {copy}
139
+ </Text>
140
+ </View>
141
+ </View>
142
+ </View>
143
+ );
144
+ }
145
+
146
+
147
+ const styles = StyleSheet.create({
148
+ root: {
149
+ // Faint scrim over the live preview so the white line-art graphic and
150
+ // caption read against bright scenes, while the preview stays
151
+ // visible underneath (the user is framing a rotation, not a shot).
152
+ backgroundColor: GUIDANCE_TOKENS.scrim,
153
+ alignItems: 'center',
154
+ justifyContent: 'center',
155
+ },
156
+ // Counter-rotated column holding the graphic + caption. Rotating this
157
+ // wrapper (not the children individually) keeps the "caption below the
158
+ // graphic" relationship intact while orienting the pair to gravity.
159
+ content: {
160
+ alignItems: 'center',
161
+ justifyContent: 'center',
162
+ },
163
+ pill: {
164
+ // Caption pill directly below the rotating-phone graphic (both are
165
+ // centred in the column by the root's center alignment).
166
+ marginTop: 16,
167
+ flexDirection: 'row',
168
+ alignItems: 'center',
169
+ paddingVertical: GUIDANCE_PILL.paddingVertical,
170
+ paddingHorizontal: GUIDANCE_PILL.paddingHorizontal,
171
+ borderRadius: GUIDANCE_PILL.borderRadius,
172
+ backgroundColor: GUIDANCE_TOKENS.scrim,
173
+ borderWidth: StyleSheet.hairlineWidth,
174
+ borderColor: GUIDANCE_TOKENS.hairline,
175
+ },
176
+ dot: {
177
+ width: GUIDANCE_PILL.dotSize,
178
+ height: GUIDANCE_PILL.dotSize,
179
+ borderRadius: GUIDANCE_PILL.dotSize / 2,
180
+ backgroundColor: GUIDANCE_TOKENS.amber,
181
+ marginRight: GUIDANCE_PILL.dotGap,
182
+ },
183
+ caption: {
184
+ color: GUIDANCE_TOKENS.white,
185
+ fontSize: GUIDANCE_PILL.fontSize,
186
+ fontWeight: GUIDANCE_PILL.fontWeight,
187
+ },
188
+ });
@@ -32,6 +32,9 @@
32
32
  import {
33
33
  DEFAULT_FLOW_GATE_SETTINGS,
34
34
  DEFAULT_PANORAMA_SETTINGS,
35
+ type BatchStitcherSettings,
36
+ type FlowGateSettings,
37
+ type FrameSelectionSettings,
35
38
  type PanoramaSettings,
36
39
  } from './PanoramaSettings';
37
40
 
@@ -60,7 +63,7 @@ export interface PanoramaPropOverrides {
60
63
  defaultKeyframeOverlapThreshold?: number;
61
64
  /**
62
65
  * Initial value for `frameSelection.maxKeyframeIntervalMs` — the
63
- * time-budget force-accept (ms). `0` disables it. Default 2000.
66
+ * time-budget force-accept (ms). `0` disables it. Default 1500.
64
67
  */
65
68
  defaultMaxKeyframeIntervalMs?: number;
66
69
  /**
@@ -69,6 +72,23 @@ export interface PanoramaPropOverrides {
69
72
  * Omitted ⇒ the stitcher default (false = bounding-rect crop).
70
73
  */
71
74
  maxInscribedRectCrop?: boolean;
75
+ /**
76
+ * v0.16 — pass the stitcher config as a JSON OBJECT (canonical field names:
77
+ * `warperType` / `blenderType` / `seamFinderType` / `stitchMode` /
78
+ * `enableMaxInscribedRectCrop`). Any field set here OVERRIDES the matching
79
+ * flat `default*` prop; unset fields fall back to the flat prop, then the SDK
80
+ * default. Partial — set only what you want.
81
+ */
82
+ stitcher?: Partial<BatchStitcherSettings>;
83
+ /**
84
+ * v0.16 — pass the frame-gate config as a JSON OBJECT (canonical field names:
85
+ * `mode` / `maxKeyframes` / `overlapThreshold` / `maxKeyframeIntervalMs` /
86
+ * `flow`). Overrides the matching flat `default*` props; `flow` is
87
+ * DEEP-merged so you can set a single flow knob without restating the rest.
88
+ */
89
+ frameSelection?: Partial<Omit<FrameSelectionSettings, 'flow'>> & {
90
+ flow?: Partial<FlowGateSettings>;
91
+ };
72
92
  }
73
93
 
74
94
 
@@ -127,6 +147,8 @@ export function buildPanoramaInitialSettings(
127
147
  enableMaxInscribedRectCrop:
128
148
  overrides.maxInscribedRectCrop
129
149
  ?? stitcherDefaults.enableMaxInscribedRectCrop,
150
+ // The JSON-object prop wins over the flat default* props above.
151
+ ...(overrides.stitcher ?? {}),
130
152
  },
131
153
 
132
154
  frameSelection: {
@@ -139,6 +161,11 @@ export function buildPanoramaInitialSettings(
139
161
  maxKeyframeIntervalMs:
140
162
  overrides.defaultMaxKeyframeIntervalMs
141
163
  ?? base.frameSelection.maxKeyframeIntervalMs,
164
+ // The JSON-object prop wins over the flat default* props above for the
165
+ // scalar fields (mode / maxKeyframes / overlapThreshold / intervalMs).
166
+ // Its `flow` (if any) is dropped here and DEEP-merged in the explicit
167
+ // `flow:` key below, so a partial flow object doesn't wipe the rest.
168
+ ...(overrides.frameSelection ?? {}),
142
169
  flow: {
143
170
  ...flowDefaults,
144
171
  noveltyPercentile:
@@ -150,6 +177,8 @@ export function buildPanoramaInitialSettings(
150
177
  maxTranslationCm:
151
178
  overrides.defaultFlowMaxTranslationCm
152
179
  ?? flowDefaults.maxTranslationCm,
180
+ // The object prop's flow wins over the flat default*Flow* props.
181
+ ...(overrides.frameSelection?.flow ?? {}),
153
182
  },
154
183
  },
155
184
  };
@@ -31,7 +31,17 @@ export interface UserFacingStitchError {
31
31
  * dropped code breaks the build here rather than silently going
32
32
  * unhandled.
33
33
  */
34
- const RECOVERABLE_STITCH_GUIDANCE: Partial<
34
+ /**
35
+ * A partial map of recoverable-error code → copy, for the `overrides`
36
+ * argument of {@link userFacingStitchError}. Hosts localising the SDK pass
37
+ * their translated strings here (typically built from their i18n catalogue,
38
+ * keyed by the same `CameraErrorCode`s exposed by {@link RECOVERABLE_STITCH_CODES}).
39
+ */
40
+ export type UserFacingStitchErrorOverrides = Partial<
41
+ Record<CameraErrorCode, UserFacingStitchError>
42
+ >;
43
+
44
+ export const RECOVERABLE_STITCH_GUIDANCE: Partial<
35
45
  Record<CameraErrorCode, UserFacingStitchError>
36
46
  > = {
37
47
  // cv::Stitcher ERR_NEED_MORE_IMGS / the manual pipeline's "0 valid
@@ -61,6 +71,15 @@ const RECOVERABLE_STITCH_GUIDANCE: Partial<
61
71
  "The frames couldn't be aligned — keep the phone level and steady so "
62
72
  + 'each frame overlaps the one before it.',
63
73
  },
74
+ // v0.16 — the post-stitch validator rejected the output as disjoint /
75
+ // fragmented: the frames stitched but didn't form one coherent panorama
76
+ // (usually a too-fast or jerky sweep that broke alignment partway).
77
+ STITCH_LOW_QUALITY: {
78
+ title: "That didn't come out right",
79
+ message:
80
+ "The panorama didn't stitch into one clean image — try again, panning "
81
+ + 'slowly and steadily in one direction so each frame overlaps the last.',
82
+ },
64
83
  // Ran out of memory finishing the stitch — usually an over-long sweep.
65
84
  STITCH_OOM: {
66
85
  title: 'Try a shorter sweep',
@@ -70,15 +89,33 @@ const RECOVERABLE_STITCH_GUIDANCE: Partial<
70
89
  },
71
90
  };
72
91
 
92
+ /**
93
+ * The recoverable stitch-error codes this module has built-in copy for.
94
+ * A host wiring i18n iterates these to know exactly which codes need a
95
+ * translation (every other `CameraErrorCode` maps to `null` and uses the
96
+ * host's generic error UI).
97
+ */
98
+ export const RECOVERABLE_STITCH_CODES = Object.keys(
99
+ RECOVERABLE_STITCH_GUIDANCE,
100
+ ) as CameraErrorCode[];
101
+
73
102
  /**
74
103
  * Maps a `CameraErrorCode` to friendly, action-guiding alert copy.
75
104
  *
105
+ * Localisation: pass `overrides` (a partial code→copy map, typically from
106
+ * your i18n catalogue) and any code present there wins over the built-in
107
+ * English; codes you omit fall back to the bundled copy. This is the
108
+ * host-side mirror of the `guidanceCopy` prop — the recoverable-error alert
109
+ * is rendered by the HOST (in its `onError` handler), so it is localised
110
+ * here rather than through `<Camera>`.
111
+ *
76
112
  * @returns the title+message for a recoverable stitch failure, or `null`
77
113
  * if `code` has no single user-recoverable action (the host should
78
114
  * then show its generic error UI).
79
115
  */
80
116
  export function userFacingStitchError(
81
117
  code: CameraErrorCode,
118
+ overrides?: UserFacingStitchErrorOverrides,
82
119
  ): UserFacingStitchError | null {
83
- return RECOVERABLE_STITCH_GUIDANCE[code] ?? null;
120
+ return overrides?.[code] ?? RECOVERABLE_STITCH_GUIDANCE[code] ?? null;
84
121
  }
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * cameraGuidanceCopy — the single user-overridable copy surface for EVERY
4
+ * string the panorama capture UI renders itself: the rotate prompt, pan
5
+ * hint, too-fast cue, lateral-stop popup, the capture-status banner
6
+ * (recording / stitching) AND the crop-editor warning banners. Centralised
7
+ * so a host can localise or re-word the whole capture experience in one
8
+ * place via the `guidanceCopy` `<Camera>` prop (see the README's
9
+ * "Internationalization" section), and so the defaults live together.
10
+ *
11
+ * NOTE on coverage: the *recoverable stitch-error* alert copy
12
+ * (`userFacingStitchError`) is rendered by the HOST (it calls that helper
13
+ * in its `onError` handler), so it is localised there — see
14
+ * `cameraErrorMessages.ts`, which accepts an override map for the same
15
+ * reason. Everything the SDK draws on screen flows through THIS object.
16
+ *
17
+ * Mirrors the override pattern of `PanoramaGuidance.messages` and
18
+ * `cameraErrorMessages.ts`.
19
+ */
20
+ import {
21
+ DEFAULT_CAPTURE_WARNING_COPY,
22
+ type CaptureWarningCopy,
23
+ } from './captureWarnings';
24
+
25
+ export interface GuidanceCopy {
26
+ /** Item 2 — caption pill while waiting for the user to rotate to landscape
27
+ * (panMode `'vertical'`). */
28
+ rotateToLandscape: string;
29
+ /** Item 2 — caption pill while waiting for the user to rotate to portrait
30
+ * (panMode `'horizontal'`). */
31
+ rotateToPortrait: string;
32
+ /** Item 3 — short hint shown with the how-to-pan animation. */
33
+ panHint: string;
34
+ /** Item 4 — transient warning when the pan is too fast. */
35
+ tooFast: string;
36
+ /** Item 6 — popup title when the user drifts laterally (cross-axis). */
37
+ lateralStopTitle: string;
38
+ /** Item 6 — popup body / guidance for the lateral-drift stop. */
39
+ lateralStopBody: string;
40
+ /** Item 6 — popup dismiss button label. */
41
+ lateralStopDismiss: string;
42
+ /**
43
+ * Item 6 — popup TITLE when lateral drift stopped the capture before
44
+ * enough frames were captured to stitch (the user panned the wrong way
45
+ * almost immediately). Nothing was produced, so the copy points them at
46
+ * the arrow instead of saying "we kept what you captured".
47
+ */
48
+ lateralWrongDirectionTitle: string;
49
+ /** Item 6 — popup BODY for the too-few-frames wrong-direction stop. */
50
+ lateralWrongDirectionBody: string;
51
+ /** Item 7 — confirm button on the crop editor. */
52
+ cropConfirm: string;
53
+ /** Item 7 — reset-corners button on the crop editor. */
54
+ cropReset: string;
55
+ /** Item 7 — "emit the stitch un-cropped" button on the crop editor. */
56
+ cropUseOriginal: string;
57
+ /** Item 7 — discard this capture and return to the camera. */
58
+ cropRetake: string;
59
+ /**
60
+ * Accept button in PREVIEW-ONLY mode (`showPreview` without `rectCrop`):
61
+ * the editor shows the stitched image with no crop box, and this confirms
62
+ * it as-is.
63
+ */
64
+ previewConfirm: string;
65
+
66
+ // ── Capture-status banner (CaptureStatusOverlay) ───────────────────────
67
+ /** Banner while a capture is recording (the calm, green state). */
68
+ statusRecording: string;
69
+ /** Banner while the panorama is being stitched after release. */
70
+ statusStitching: string;
71
+
72
+ // ── Crop-editor warning banner (buildCaptureWarnings) ──────────────────
73
+ // These re-use the capture-warning defaults verbatim (single source of
74
+ // truth in `captureWarnings.ts`); overriding them here re-words BOTH the
75
+ // crop-banner text AND the `message` carried on `onCapture(...).warnings`.
76
+ /**
77
+ * LOW_FRAME_UTILIZATION warning. TEMPLATE — keep the `{included}`,
78
+ * `{requested}` and `{percent}` placeholders (substituted at runtime).
79
+ */
80
+ warnLowFrameUtilization: string;
81
+ /** LATERAL_DRIFT_FINALIZE warning. */
82
+ warnLateralDriftFinalize: string;
83
+ /** HIGH_PAN_SPEED warning. */
84
+ warnHighPanSpeed: string;
85
+ }
86
+
87
+ export const DEFAULT_GUIDANCE_COPY: GuidanceCopy = {
88
+ rotateToLandscape: 'Rotate to landscape',
89
+ rotateToPortrait: 'Rotate to portrait',
90
+ panHint: 'Pan slowly top to bottom',
91
+ tooFast: 'Moving too fast — slow down',
92
+ lateralStopTitle: 'Keep the pan straight',
93
+ lateralStopBody:
94
+ 'You moved sideways. Pan in one direction only — we stitched what you captured.',
95
+ lateralStopDismiss: 'Got it',
96
+ lateralWrongDirectionTitle: 'Follow the arrow',
97
+ lateralWrongDirectionBody:
98
+ 'You moved the phone the wrong way. Pan slowly in the direction the '
99
+ + 'arrow shows, in one straight line.',
100
+ cropConfirm: 'Crop',
101
+ cropReset: 'Reset',
102
+ cropUseOriginal: 'Use original',
103
+ cropRetake: 'Retake',
104
+ previewConfirm: 'Confirm',
105
+ statusRecording: 'Hold steady — pan slowly',
106
+ statusStitching: 'Stitching panorama…',
107
+ // DRY: the English warning copy lives once, in captureWarnings.ts.
108
+ warnLowFrameUtilization: DEFAULT_CAPTURE_WARNING_COPY.lowFrameUtilization,
109
+ warnLateralDriftFinalize: DEFAULT_CAPTURE_WARNING_COPY.lateralDriftFinalize,
110
+ warnHighPanSpeed: DEFAULT_CAPTURE_WARNING_COPY.highPanSpeed,
111
+ };
112
+
113
+ /**
114
+ * Project the warning keys of a resolved `GuidanceCopy` back onto the
115
+ * {@link CaptureWarningCopy} shape `buildCaptureWarnings` consumes. Keeps
116
+ * the two call sites in `<Camera>` from re-spelling the mapping (DRY).
117
+ */
118
+ export function captureWarningCopyFrom(g: GuidanceCopy): CaptureWarningCopy {
119
+ return {
120
+ lowFrameUtilization: g.warnLowFrameUtilization,
121
+ lateralDriftFinalize: g.warnLateralDriftFinalize,
122
+ highPanSpeed: g.warnHighPanSpeed,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Merge a partial host override onto the defaults. Undefined / missing keys
128
+ * fall back to the default string; an empty-object / undefined override
129
+ * returns the defaults unchanged.
130
+ */
131
+ export function mergeGuidanceCopy(
132
+ override?: Partial<GuidanceCopy>,
133
+ ): GuidanceCopy {
134
+ if (!override) return DEFAULT_GUIDANCE_COPY;
135
+ return { ...DEFAULT_GUIDANCE_COPY, ...stripUndefined(override) };
136
+ }
137
+
138
+ /** Drop keys whose value is `undefined` so they don't clobber a default. */
139
+ function stripUndefined(o: Partial<GuidanceCopy>): Partial<GuidanceCopy> {
140
+ const out: Partial<GuidanceCopy> = {};
141
+ (Object.keys(o) as (keyof GuidanceCopy)[]).forEach((k) => {
142
+ if (o[k] !== undefined) out[k] = o[k];
143
+ });
144
+ return out;
145
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * captureCountdown — pure timing helpers for the recording-time countdown
4
+ * and auto-finalize (guidance item 5).
5
+ *
6
+ * The non-AR panorama hold-and-pan has a hard recording ceiling (`maxMs`).
7
+ * As the user pans, a blinking countdown shows the whole seconds remaining;
8
+ * when it hits 0 the host auto-finalizes (stops recording and stitches what
9
+ * was captured — the FINALIZE-on-zero decision is handled by `<Camera>`,
10
+ * not here).
11
+ *
12
+ * Both functions are pure (no React, no timers, no `Date.now()` baked in —
13
+ * the caller threads `now` from its own animation frame / interval) so the
14
+ * boundary behaviour is unit-testable in the node jest env. Mirrors the
15
+ * pure-helper + `__tests__` pattern of `contentRotationDeg`.
16
+ *
17
+ * `maxMs <= 0` DISABLES the feature: `shouldAutoStop` never returns true
18
+ * (recording is unbounded) and the countdown is meant to be hidden by the
19
+ * caller. `countdownSecondsFrom` still returns a clamped, non-negative
20
+ * number in that case (0) so a caller that renders it regardless won't show
21
+ * a negative value.
22
+ */
23
+
24
+
25
+ /**
26
+ * Whole seconds remaining in the recording window, for the countdown UI.
27
+ *
28
+ * - While recording (`recordingStartedAt` non-null):
29
+ * `ceil((maxMs - elapsed) / 1000)`, where `elapsed = now - start`,
30
+ * clamped to `[0, round(maxMs / 1000)]`. `ceil` means the displayed
31
+ * number ticks to N only once strictly fewer than N seconds remain
32
+ * (e.g. at exactly 1 ms before the 1s boundary it still reads the
33
+ * higher value), and it reaches 0 exactly at `elapsed === maxMs`.
34
+ * - Before recording (`recordingStartedAt === null`): the full window,
35
+ * `round(maxMs / 1000)` — the at-rest value shown before the user
36
+ * starts the hold.
37
+ * - `maxMs <= 0` (feature disabled): returns 0.
38
+ *
39
+ * The result is always a whole, non-negative number.
40
+ */
41
+ export function countdownSecondsFrom(
42
+ recordingStartedAt: number | null,
43
+ now: number,
44
+ maxMs: number,
45
+ ): number {
46
+ if (maxMs <= 0) return 0;
47
+
48
+ const maxSeconds = Math.round(maxMs / 1000);
49
+
50
+ // Not recording yet — show the full window at rest.
51
+ if (recordingStartedAt === null) return maxSeconds;
52
+
53
+ const elapsed = now - recordingStartedAt;
54
+ const remainingSeconds = Math.ceil((maxMs - elapsed) / 1000);
55
+
56
+ // Clamp into [0, maxSeconds]: guards a clock that ran past the ceiling
57
+ // (negative remaining → 0) and a `now` before the start (`elapsed < 0`,
58
+ // remaining > maxSeconds → maxSeconds).
59
+ return Math.min(maxSeconds, Math.max(0, remainingSeconds));
60
+ }
61
+
62
+
63
+ /**
64
+ * True when the host should auto-finalize the recording NOW.
65
+ *
66
+ * Fires only when ALL of:
67
+ * 1. recording (`recordingStartedAt` non-null),
68
+ * 2. the window is enabled (`maxMs > 0`), AND
69
+ * 3. elapsed (`now - start`) has reached or passed the ceiling
70
+ * (`>= maxMs`).
71
+ *
72
+ * `maxMs <= 0` disables auto-stop entirely (unbounded recording), so this
73
+ * returns false regardless of how long the user has been recording.
74
+ */
75
+ export function shouldAutoStop(
76
+ recordingStartedAt: number | null,
77
+ now: number,
78
+ maxMs: number,
79
+ ): boolean {
80
+ if (recordingStartedAt === null) return false;
81
+ if (maxMs <= 0) return false;
82
+ return now - recordingStartedAt >= maxMs;
83
+ }