react-native-image-stitcher 0.15.1 → 0.16.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.
- package/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* pickCaptureFormat — choose the vision-camera format for the capture stream.
|
|
5
|
+
*
|
|
6
|
+
* Replaces a plain `useCameraFormat([{ videoResolution: 'max' }, …])`, which
|
|
7
|
+
* picks the device's MAX-video format and lets the PHOTO resolution ride
|
|
8
|
+
* along — on the iPhone 16 Pro ultra-wide that pairs a **48 MP** still
|
|
9
|
+
* (8064×6048) with the 4032×3024 max-video format, so a tap photo came out
|
|
10
|
+
* ~6000 px. vision-camera 4.x exposes each format's photo/video resolution
|
|
11
|
+
* but NOT its pixel format / bit-depth, so we can't filter for 8-bit; the
|
|
12
|
+
* empirical rule is that the device's MAX 4:3 video format is 8-bit (the
|
|
13
|
+
* frame processor needs 8-bit for non-AR stitching), and lower video
|
|
14
|
+
* resolutions risk 10-bit.
|
|
15
|
+
*
|
|
16
|
+
* Strategy: among the ~4:3 formats whose photo long-edge is within
|
|
17
|
+
* `maxPhotoLongEdge`, pick the one with the HIGHEST video resolution (keeps
|
|
18
|
+
* the preview/stitch stream as sharp as possible while bounding the still),
|
|
19
|
+
* tie-breaking on higher fps, then the largest photo under the cap, then
|
|
20
|
+
* non-HDR (a hedge toward 8-bit). If NO format fits the cap, fall back to
|
|
21
|
+
* the overall max-video format (never returns nothing for a non-empty list).
|
|
22
|
+
*
|
|
23
|
+
* Verified against the real iPhone 16 Pro ultra-wide format list (see the
|
|
24
|
+
* unit test): cap 4032 → 4032×3024 photo (12 MP) + 3264×2448 video (was
|
|
25
|
+
* 8064×6048 photo); cap 2048 → 2016×1512 photo (3 MP) + 1920×1440 video.
|
|
26
|
+
*
|
|
27
|
+
* Pure + structurally-typed (no vision-camera import) so it unit-tests in the
|
|
28
|
+
* node jest env; `CameraDeviceFormat` is structurally assignable to
|
|
29
|
+
* `FormatLike`.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.pickCaptureFormat = pickCaptureFormat;
|
|
33
|
+
const DEFAULT_MAX_PHOTO_LONG_EDGE = 4032;
|
|
34
|
+
const DEFAULT_FPS_TARGET = 60;
|
|
35
|
+
const longEdge = (f) => Math.max(f.photoWidth, f.photoHeight);
|
|
36
|
+
const videoPixels = (f) => f.videoWidth * f.videoHeight;
|
|
37
|
+
/**
|
|
38
|
+
* Pick the best capture format, or `undefined` for an empty list.
|
|
39
|
+
*/
|
|
40
|
+
function pickCaptureFormat(formats, opts = {}) {
|
|
41
|
+
if (!formats || formats.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
const aspect = opts.aspect ?? 4 / 3;
|
|
44
|
+
const tol = opts.aspectTolerance ?? 0.05;
|
|
45
|
+
const cap = opts.maxPhotoLongEdge ?? DEFAULT_MAX_PHOTO_LONG_EDGE;
|
|
46
|
+
const preferHighFps = opts.preferHighFps ?? false;
|
|
47
|
+
const fpsTarget = opts.fpsTarget ?? DEFAULT_FPS_TARGET;
|
|
48
|
+
// Treat everything at/above the target as equally smooth so resolution, not
|
|
49
|
+
// a chase for 120 fps, breaks the tie.
|
|
50
|
+
const smoothness = (f) => Math.min(f.maxFps, fpsTarget);
|
|
51
|
+
const matchesAspect = (f) => f.photoHeight > 0
|
|
52
|
+
&& f.videoHeight > 0
|
|
53
|
+
&& Math.abs(f.photoWidth / f.photoHeight - aspect) < tol
|
|
54
|
+
&& Math.abs(f.videoWidth / f.videoHeight - aspect) < tol;
|
|
55
|
+
// Prefer 4:3 formats; if the device has none, consider all.
|
|
56
|
+
const fourThree = formats.filter(matchesAspect);
|
|
57
|
+
const base = fourThree.length > 0 ? fourThree : formats.slice();
|
|
58
|
+
// Among those within the photo cap; if none fit, fall back to all (which
|
|
59
|
+
// then resolves to the max-video format — never worse than today).
|
|
60
|
+
const withinCap = cap > 0 ? base.filter((f) => longEdge(f) <= cap) : base.slice();
|
|
61
|
+
const candidates = withinCap.length > 0 ? withinCap : base;
|
|
62
|
+
return candidates.slice().sort((a, b) => {
|
|
63
|
+
if (preferHighFps) {
|
|
64
|
+
// Smooth-preview priority: frame rate (up to the target) before video
|
|
65
|
+
// resolution. Keeps the panorama preview at ~60 fps instead of dropping
|
|
66
|
+
// to a sharper-but-30fps format.
|
|
67
|
+
const sa = smoothness(a);
|
|
68
|
+
const sb = smoothness(b);
|
|
69
|
+
if (sb !== sa)
|
|
70
|
+
return sb - sa;
|
|
71
|
+
}
|
|
72
|
+
const va = videoPixels(a);
|
|
73
|
+
const vb = videoPixels(b);
|
|
74
|
+
if (vb !== va)
|
|
75
|
+
return vb - va; // highest video resolution first
|
|
76
|
+
if (b.maxFps !== a.maxFps)
|
|
77
|
+
return b.maxFps - a.maxFps; // then higher fps
|
|
78
|
+
if (longEdge(b) !== longEdge(a))
|
|
79
|
+
return longEdge(b) - longEdge(a); // largest photo under cap
|
|
80
|
+
// Prefer non-HDR — a hedge toward an 8-bit pixel format (the stitch
|
|
81
|
+
// frame processor needs 8-bit; vision-camera doesn't expose bit-depth).
|
|
82
|
+
return (a.supportsVideoHdr ? 1 : 0) - (b.supportsVideoHdr ? 1 : 0);
|
|
83
|
+
})[0];
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=pickCaptureFormat.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildStitchDebugInfo — format the stitcher's runtime stats as a compact,
|
|
3
|
+
* multi-line string for the __DEV__-only overlay on the output preview.
|
|
4
|
+
*
|
|
5
|
+
* The operator uses this to SEE how a panorama was built — which pipeline +
|
|
6
|
+
* warper ran, whether the low-memory stream/feather fallback kicked in, the
|
|
7
|
+
* confidence score the successful attempt used, and how many keyframes
|
|
8
|
+
* survived pruning. Purely presentational; never shown in release.
|
|
9
|
+
*
|
|
10
|
+
* Pure + structurally typed so it unit-tests in the node jest env.
|
|
11
|
+
*/
|
|
12
|
+
export interface StitchDebugFields {
|
|
13
|
+
/** Native `debugSummary`: `"pipe=…;warp=…;route=…;seam=…;blend=…"`. */
|
|
14
|
+
debugSummary?: string;
|
|
15
|
+
stitchModeResolved?: 'panorama' | 'scans';
|
|
16
|
+
finalConfidenceThresh?: number;
|
|
17
|
+
framesIncluded?: number;
|
|
18
|
+
framesRequested?: number;
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the overlay text. Returns `''` when nothing useful is present (so the
|
|
24
|
+
* caller can skip rendering the pill entirely). One `key: value` per line.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildStitchDebugInfo(r: StitchDebugFields): string;
|
|
27
|
+
//# sourceMappingURL=stitchDebugInfo.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* buildStitchDebugInfo — format the stitcher's runtime stats as a compact,
|
|
5
|
+
* multi-line string for the __DEV__-only overlay on the output preview.
|
|
6
|
+
*
|
|
7
|
+
* The operator uses this to SEE how a panorama was built — which pipeline +
|
|
8
|
+
* warper ran, whether the low-memory stream/feather fallback kicked in, the
|
|
9
|
+
* confidence score the successful attempt used, and how many keyframes
|
|
10
|
+
* survived pruning. Purely presentational; never shown in release.
|
|
11
|
+
*
|
|
12
|
+
* Pure + structurally typed so it unit-tests in the node jest env.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.buildStitchDebugInfo = buildStitchDebugInfo;
|
|
16
|
+
/**
|
|
17
|
+
* Build the overlay text. Returns `''` when nothing useful is present (so the
|
|
18
|
+
* caller can skip rendering the pill entirely). One `key: value` per line.
|
|
19
|
+
*/
|
|
20
|
+
function buildStitchDebugInfo(r) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
// Expand the native summary ("pipe=manual;warp=spherical;…") into one
|
|
23
|
+
// labelled line per pair, preserving order. Malformed pairs are skipped.
|
|
24
|
+
if (r.debugSummary) {
|
|
25
|
+
for (const pair of r.debugSummary.split(';')) {
|
|
26
|
+
const eq = pair.indexOf('=');
|
|
27
|
+
if (eq <= 0)
|
|
28
|
+
continue;
|
|
29
|
+
const key = pair.slice(0, eq).trim();
|
|
30
|
+
const value = pair.slice(eq + 1).trim();
|
|
31
|
+
if (key && value)
|
|
32
|
+
lines.push(`${key}: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (r.stitchModeResolved)
|
|
36
|
+
lines.push(`mode: ${r.stitchModeResolved}`);
|
|
37
|
+
if (typeof r.finalConfidenceThresh === 'number'
|
|
38
|
+
&& r.finalConfidenceThresh >= 0) {
|
|
39
|
+
lines.push(`score: ${r.finalConfidenceThresh.toFixed(2)}`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof r.framesIncluded === 'number' && r.framesIncluded >= 0) {
|
|
42
|
+
const req = typeof r.framesRequested === 'number' && r.framesRequested >= 0
|
|
43
|
+
? String(r.framesRequested)
|
|
44
|
+
: '?';
|
|
45
|
+
lines.push(`frames: ${r.framesIncluded}/${req}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof r.width === 'number'
|
|
48
|
+
&& typeof r.height === 'number'
|
|
49
|
+
&& r.width > 0
|
|
50
|
+
&& r.height > 0) {
|
|
51
|
+
lines.push(`size: ${r.width}×${r.height}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=stitchDebugInfo.js.map
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePanMotion — one sensor-fed hook that exposes the three motion
|
|
3
|
+
* signals the first-time-user GUIDANCE surfaces share, so the screen
|
|
4
|
+
* spins up ONE gyroscope + ONE accelerometer subscription instead of
|
|
5
|
+
* three independent ones.
|
|
6
|
+
*
|
|
7
|
+
* Consumers
|
|
8
|
+
* - Item 3 (pan how-to / direction arrow) wants `resolvedAxis` to
|
|
9
|
+
* know whether the user is panning horizontally or vertically.
|
|
10
|
+
* - Item 4 ("Moving too fast, slow down") wants `panSpeedBucket`.
|
|
11
|
+
* - Item 6 (lateral-drift → finalize + popup) wants `lateralCm` /
|
|
12
|
+
* `lateralExceeded`.
|
|
13
|
+
*
|
|
14
|
+
* Why one hook (and not three components each subscribing)
|
|
15
|
+
* `react-native-sensors` is global: every `gyroscope.subscribe` /
|
|
16
|
+
* `accelerometer.subscribe` adds a listener to the same underlying
|
|
17
|
+
* native sensor, and `setUpdateIntervalForType` is process-wide.
|
|
18
|
+
* Three subscribers means three JS callbacks per native sample +
|
|
19
|
+
* three teardown paths to get right. Funnelling the shared signals
|
|
20
|
+
* through one hook keeps the sensor wiring in a single place.
|
|
21
|
+
*
|
|
22
|
+
* ── Speed bucket (Item 4) ────────────────────────────────────────
|
|
23
|
+
* Reuses `PanoramaGuidance`'s gyro logic verbatim (see `bucketFor`
|
|
24
|
+
* below, lifted from that file): take the dominant rotation axis for
|
|
25
|
+
* the current pan direction and map |rad/s| onto good / warn / bad.
|
|
26
|
+
* horizontal pan (portrait, Mode B) → gyro Y dominates.
|
|
27
|
+
* vertical pan (landscape, Mode A) → gyro X dominates.
|
|
28
|
+
* Defaults 0.5 / 1.0 rad/s match `PanoramaGuidance`'s SCANS tuning.
|
|
29
|
+
*
|
|
30
|
+
* ── Lateral drift (Item 6) ───────────────────────────────────────
|
|
31
|
+
* This is the subtle part. `useIMUTranslationGate` integrates the
|
|
32
|
+
* accelerometer along **device-X**, because in BOTH pan modes the
|
|
33
|
+
* pan axis maps to device-X (portrait: user left/right; landscape:
|
|
34
|
+
* device-X has rotated 90° into user up/down). That gate's X
|
|
35
|
+
* integrator RESETS at every accepted keyframe (and auto-rearms on
|
|
36
|
+
* each budget fire) — see its header — because it measures
|
|
37
|
+
* translation-*along*-the-pan between keyframes.
|
|
38
|
+
*
|
|
39
|
+
* Lateral drift is the ORTHOGONAL motion: the operator sliding the
|
|
40
|
+
* phone sideways out of the pan plane. Orthogonal to device-X is
|
|
41
|
+
* **device-Y**, in both modes. So we integrate device-Y here.
|
|
42
|
+
*
|
|
43
|
+
* Crucially this accumulator must measure drift over the WHOLE
|
|
44
|
+
* capture, not per-keyframe — a slow continuous sideways creep would
|
|
45
|
+
* never trip a per-keyframe-reset budget. So unlike the gate's
|
|
46
|
+
* `posX`, our `posY` resets ONLY on `active` false → true (capture
|
|
47
|
+
* start). It is never reset by keyframe accepts (this hook doesn't
|
|
48
|
+
* even know about them).
|
|
49
|
+
*
|
|
50
|
+
* We borrow the gate's drift-mitigation recipe (per-axis IIR gravity
|
|
51
|
+
* estimate + per-sample velocity damping + iOS G→m/s² scaling) so the
|
|
52
|
+
* lateral integrator has the same noise floor characteristics.
|
|
53
|
+
*
|
|
54
|
+
* Grace window
|
|
55
|
+
* A short slide as the operator settles their grip at capture start
|
|
56
|
+
* shouldn't fire the "you drifted" popup. `lateralExceeded` only
|
|
57
|
+
* latches once the budget has been *continuously* exceeded for
|
|
58
|
+
* `LATERAL_GRACE_MS` (default 500 ms). A dip back under budget
|
|
59
|
+
* resets the grace timer, so a single wobble that crosses and
|
|
60
|
+
* immediately recrosses the threshold never latches. Once latched
|
|
61
|
+
* it STAYS latched until the next capture (matches Item 6's
|
|
62
|
+
* product decision: finalize what's captured, then show the popup —
|
|
63
|
+
* we don't un-finalize if the phone wobbles back).
|
|
64
|
+
*
|
|
65
|
+
* Performance
|
|
66
|
+
* Gyro at ~30 Hz, accel at ~50 Hz, all integrator state in refs.
|
|
67
|
+
* `setState` fires only on a *qualitative* change (bucket flips, or
|
|
68
|
+
* the exceeded latch trips) — never per sample. `lateralCm` is the
|
|
69
|
+
* one exception consumers may want live; it's exposed via the
|
|
70
|
+
* returned object but only re-rendered on the throttled tick (see
|
|
71
|
+
* `LATERAL_EMIT_INTERVAL_MS`) so a debug/HUD readout updates without
|
|
72
|
+
* a 50 Hz re-render storm.
|
|
73
|
+
*/
|
|
74
|
+
export type PanSpeedBucket = 'good' | 'warn' | 'bad';
|
|
75
|
+
/**
|
|
76
|
+
* Pan axis in user-perceived terms:
|
|
77
|
+
* 'horizontal' → portrait, pan left↔right (Mode B).
|
|
78
|
+
* 'vertical' → landscape, pan up↕down (Mode A).
|
|
79
|
+
* Same vocabulary as `PanoramaGuidance`'s `PanAxis`.
|
|
80
|
+
*/
|
|
81
|
+
export type PanAxis = 'horizontal' | 'vertical';
|
|
82
|
+
export interface UsePanMotionOptions {
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to the sensors only while this is true. Typically the
|
|
85
|
+
* host's `statusPhase === 'recording'`. Teardown on inactive so
|
|
86
|
+
* the gyro/accel aren't running the rest of the time the screen is
|
|
87
|
+
* up. The lateral accumulator zeroes on every false → true edge.
|
|
88
|
+
*/
|
|
89
|
+
active: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Force the pan axis instead of auto-detecting from device
|
|
92
|
+
* orientation. Matches `PanoramaGuidance`'s `axis` prop: hosts
|
|
93
|
+
* that lock orientation but want the user to pan the orthogonal
|
|
94
|
+
* axis pass this. Default: auto-detect — 'horizontal' in portrait,
|
|
95
|
+
* 'vertical' in landscape.
|
|
96
|
+
*/
|
|
97
|
+
axis?: PanAxis;
|
|
98
|
+
/**
|
|
99
|
+
* Gyro rate (rad/s) at/below which the pan is 'good'. Default 0.5,
|
|
100
|
+
* same as `PanoramaGuidance`'s SCANS tuning.
|
|
101
|
+
*/
|
|
102
|
+
goodMaxRadPerSec?: number;
|
|
103
|
+
/**
|
|
104
|
+
* Gyro rate (rad/s) at/below which the pan is 'warn' (above 'good').
|
|
105
|
+
* Above it is 'bad'. Default 1.0.
|
|
106
|
+
*/
|
|
107
|
+
warnMaxRadPerSec?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Lateral (cross-pan) translation budget in CENTIMETRES. Once the
|
|
110
|
+
* integrated |lateral| exceeds this for `LATERAL_GRACE_MS`,
|
|
111
|
+
* `lateralExceeded` latches true. Default 5 cm.
|
|
112
|
+
*/
|
|
113
|
+
lateralBudgetCm?: number;
|
|
114
|
+
}
|
|
115
|
+
export interface UsePanMotionReturn {
|
|
116
|
+
/** Qualitative pan speed for the dominant gyro axis. */
|
|
117
|
+
panSpeedBucket: PanSpeedBucket;
|
|
118
|
+
/**
|
|
119
|
+
* Signed lateral (cross-pan) translation since capture start, in
|
|
120
|
+
* centimetres. Updates on a throttled tick (~10 Hz), not per
|
|
121
|
+
* sample. Useful for a debug/HUD readout; the latch decision uses
|
|
122
|
+
* the un-throttled internal value.
|
|
123
|
+
*/
|
|
124
|
+
lateralCm: number;
|
|
125
|
+
/**
|
|
126
|
+
* `true` once |lateralCm| has exceeded `lateralBudgetCm`
|
|
127
|
+
* continuously for the grace window. Latching — stays true until
|
|
128
|
+
* the next capture (`active` false → true).
|
|
129
|
+
*/
|
|
130
|
+
lateralExceeded: boolean;
|
|
131
|
+
/** Resolved pan axis (after auto-detect / `axis` override). */
|
|
132
|
+
resolvedAxis: PanAxis;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Map a signed rotation rate (rad/s) onto the qualitative speed
|
|
136
|
+
* bucket. Pure — exported for tests. Lifted verbatim from
|
|
137
|
+
* `PanoramaGuidance.bucketFor` so the two surfaces never diverge.
|
|
138
|
+
*
|
|
139
|
+
* Thresholds are INCLUSIVE of the lower band: `|rate| <= good` is
|
|
140
|
+
* 'good', `|rate| <= warn` is 'warn', otherwise 'bad'.
|
|
141
|
+
*/
|
|
142
|
+
export declare function _bucketForRate(rate: number, good: number, warn: number): PanSpeedBucket;
|
|
143
|
+
/**
|
|
144
|
+
* Pick the dominant gyro axis value for a pan direction. Mirrors
|
|
145
|
+
* `PanoramaGuidance`'s `resolvedAxis === 'horizontal' ? y : x`:
|
|
146
|
+
* horizontal pan (portrait) → gyro Y dominates.
|
|
147
|
+
* vertical pan (landscape) → gyro X dominates.
|
|
148
|
+
* Pure — exported for tests.
|
|
149
|
+
*/
|
|
150
|
+
export declare function _gyroRateForAxis(axis: PanAxis, gyro: {
|
|
151
|
+
x: number;
|
|
152
|
+
y: number;
|
|
153
|
+
}): number;
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the pan axis the same way `PanoramaGuidance` does:
|
|
156
|
+
* explicit `axis` override wins; otherwise portrait → 'horizontal',
|
|
157
|
+
* landscape → 'vertical'. Pure — exported for tests.
|
|
158
|
+
*/
|
|
159
|
+
export declare function _resolvePanAxis(orientation: 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right', override?: PanAxis): PanAxis;
|
|
160
|
+
/**
|
|
161
|
+
* Internal lateral-integrator state. One device axis (device-Y, the
|
|
162
|
+
* cross-pan axis) integrated to a position, with the same IIR-gravity
|
|
163
|
+
* + velocity-damping recipe as `useIMUTranslationGate`'s X gate.
|
|
164
|
+
*
|
|
165
|
+
* Unlike that gate, `pos` is NEVER reset by keyframe accepts — only by
|
|
166
|
+
* `resetLateralState` at capture start — so it accumulates total
|
|
167
|
+
* cross-pan drift across the whole capture.
|
|
168
|
+
*/
|
|
169
|
+
export interface LateralState {
|
|
170
|
+
/** Integrated cross-pan position, METRES. */
|
|
171
|
+
pos: number;
|
|
172
|
+
/** Integrated cross-pan velocity, m/s. */
|
|
173
|
+
vel: number;
|
|
174
|
+
/** IIR-estimated gravity component on the cross-pan axis (m/s²). */
|
|
175
|
+
gravity: number;
|
|
176
|
+
/**
|
|
177
|
+
* `true` once the latch has tripped; stays true for the capture.
|
|
178
|
+
* Mirrors `useIMUTranslationGate`'s `fired`, but here it never
|
|
179
|
+
* auto-rearms (drift is a one-shot finalize, not a re-trigger).
|
|
180
|
+
*/
|
|
181
|
+
exceeded: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Timestamp (ms, performance.now-style) at which |pos| first went
|
|
184
|
+
* over budget in the current continuous over-budget run, or `null`
|
|
185
|
+
* if currently under budget. Drives the grace window.
|
|
186
|
+
*/
|
|
187
|
+
overBudgetSinceMs: number | null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Result of one grace-window latch evaluation: the (possibly latched)
|
|
191
|
+
* exceeded flag + the (possibly cleared/started) continuous-over-budget
|
|
192
|
+
* timer. See `_evalGraceLatch`.
|
|
193
|
+
*/
|
|
194
|
+
export interface GraceLatchResult {
|
|
195
|
+
exceeded: boolean;
|
|
196
|
+
overBudgetSinceMs: number | null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Pure grace-window latch decision, factored out of the integrator so
|
|
200
|
+
* the debounce is testable without constructing a physical
|
|
201
|
+
* acceleration profile. Given whether |pos| is currently over budget,
|
|
202
|
+
* the clock, and the prior latch/timer state, decide the next state:
|
|
203
|
+
*
|
|
204
|
+
* - under budget → clear the timer; never un-latch.
|
|
205
|
+
* - over budget, no timer → start the timer (note: NOT yet latched).
|
|
206
|
+
* - over budget, timer old → latch once `now - since >= graceMs`.
|
|
207
|
+
* - already latched → stays latched forever (one-shot
|
|
208
|
+
* finalize; see header).
|
|
209
|
+
*
|
|
210
|
+
* @param overBudget is |pos| currently over the budget?
|
|
211
|
+
* @param nowMs monotonic clock, ms
|
|
212
|
+
* @param prevSinceMs timestamp |pos| first went over in the current
|
|
213
|
+
* continuous run, or `null` if previously under
|
|
214
|
+
* @param prevExceeded already-latched flag from the prior sample
|
|
215
|
+
* @param graceMs continuous dwell required before latching
|
|
216
|
+
*/
|
|
217
|
+
export declare function _evalGraceLatch(overBudget: boolean, nowMs: number, prevSinceMs: number | null, prevExceeded: boolean, graceMs: number): GraceLatchResult;
|
|
218
|
+
/** A fresh integrator, as seeded at every capture start. */
|
|
219
|
+
export declare function _freshLateralState(): LateralState;
|
|
220
|
+
/**
|
|
221
|
+
* Reset the integrator in place for a new capture. Zeroes position,
|
|
222
|
+
* velocity, the latch, and the grace timer, and re-arms gravity
|
|
223
|
+
* seeding. Pure (mutates the passed object, returns it) — exported so
|
|
224
|
+
* tests can assert the "resets only at capture start" contract.
|
|
225
|
+
*/
|
|
226
|
+
export declare function _resetLateralState(s: LateralState): LateralState;
|
|
227
|
+
/**
|
|
228
|
+
* Advance the lateral integrator by one accelerometer sample. Pure
|
|
229
|
+
* (mutates + returns the passed state) so the integration math is
|
|
230
|
+
* unit-testable without a sensor or a React render.
|
|
231
|
+
*
|
|
232
|
+
* @param s running integrator state (mutated in place)
|
|
233
|
+
* @param rawAxis raw cross-pan accel reading for this sample, in the
|
|
234
|
+
* platform's native unit (G's on iOS, m/s² on
|
|
235
|
+
* Android) — caller has NOT yet applied `scale`
|
|
236
|
+
* @param scale unit scale (G_TO_MPS2 on iOS, 1 on Android)
|
|
237
|
+
* @param dt sample period, seconds
|
|
238
|
+
* @param budgetM lateral budget, METRES
|
|
239
|
+
* @param graceMs continuous-over-budget dwell before latching, ms
|
|
240
|
+
* @param nowMs monotonic clock for this sample, ms
|
|
241
|
+
* @returns the same `s` (mutated): `pos` is the new cross-pan position
|
|
242
|
+
* in metres; `exceeded` is the latched flag.
|
|
243
|
+
*
|
|
244
|
+
* NOTE the first call only seeds gravity and returns with `pos`
|
|
245
|
+
* unchanged (matches `useIMUTranslationGate`'s first-sample handling)
|
|
246
|
+
* — the first reading is assumed to be ~stationary at capture start.
|
|
247
|
+
*/
|
|
248
|
+
export declare function _integrateLateralSample(s: LateralState, rawAxis: number, scale: number, dt: number, budgetM: number, graceMs: number, nowMs: number): LateralState;
|
|
249
|
+
export declare function usePanMotion({ active, axis, goodMaxRadPerSec, warnMaxRadPerSec, lateralBudgetCm, }: UsePanMotionOptions): UsePanMotionReturn;
|
|
250
|
+
//# sourceMappingURL=usePanMotion.d.ts.map
|