react-native-image-stitcher 0.3.0 → 0.4.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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * buildPanoramaInitialSettings — pure helper that materialises the
3
+ * initial `PanoramaSettings` snapshot from <Camera>'s `default*` props
4
+ * and a device-capability hint.
5
+ *
6
+ * Why a separate file?
7
+ * ────────────────────
8
+ *
9
+ * The settings tree lives in `PanoramaSettings.ts`; <Camera> consumes
10
+ * it and writes it into React state. The translation FROM the prop
11
+ * surface (flat names like `defaultStitchMode`) INTO the hierarchical
12
+ * settings tree is the part that:
13
+ *
14
+ * • is non-trivial enough to deserve direct unit-test coverage
15
+ * (covers the prop→sub-tree path mapping, which is easy to drift),
16
+ * • is pure TS — no React, no React Native — so the test runs in
17
+ * jest's `node` environment without needing the `react-native`
18
+ * preset (the rest of <Camera> is unmockable in pure TS).
19
+ *
20
+ * Living alongside `Camera.tsx` (vs. burying it as a private function
21
+ * inside) is the only way to get those two properties without taking
22
+ * on full React-Native jest setup just for this one helper.
23
+ *
24
+ * The exported `PanoramaPropOverrides` type is the prop-fragment
25
+ * <Camera> uses; `CameraProps` extends it. Keeping it explicit here
26
+ * means future Camera prop additions don't accidentally widen the
27
+ * settings-translation surface — every consumer of the helper sees
28
+ * exactly the prop fields that drive the settings tree.
29
+ */
30
+ import { type PanoramaSettings } from './PanoramaSettings';
31
+ /**
32
+ * Subset of <Camera>'s props that map onto fields of the initial
33
+ * `PanoramaSettings` snapshot. Anything outside this interface
34
+ * (e.g. `defaultLens`, `enablePhotoMode`, callbacks) is irrelevant
35
+ * to the settings shape and stays in `CameraProps` only.
36
+ *
37
+ * Forward-looking `default*ResolMP` props are documented here but
38
+ * intentionally not translated yet — the new `PanoramaSettings` tree
39
+ * has no home for them (the v0.3 audit found cv::Stitcher's resol
40
+ * knobs aren't reached by the current native bridges).
41
+ */
42
+ export interface PanoramaPropOverrides {
43
+ defaultCaptureSource?: 'ar' | 'non-ar';
44
+ defaultStitchMode?: 'auto' | 'panorama' | 'scans';
45
+ defaultBlender?: 'multiband' | 'feather';
46
+ defaultSeamFinder?: 'graphcut' | 'skip';
47
+ defaultWarper?: 'plane' | 'cylindrical' | 'spherical';
48
+ defaultFlowNoveltyPercentile?: number;
49
+ defaultFlowEvalEveryNFrames?: number;
50
+ defaultFlowMaxTranslationCm?: number;
51
+ defaultKeyframeMaxCount?: number;
52
+ defaultKeyframeOverlapThreshold?: number;
53
+ }
54
+ /**
55
+ * Whether this device is low-memory enough to benefit from the
56
+ * feather+skip blender/seam fallback (vs. the heavier multiband+
57
+ * graphcut default). <Camera> derives this from
58
+ * `NativeModules.BatchStitcher.physicalMemoryBytes` at module load
59
+ * (RN-only — see `getIsLowMemDevice` in Camera.tsx); tests pass
60
+ * `false` explicitly to keep the prop-translation path the unit of
61
+ * the unit test.
62
+ *
63
+ * Why a parameter and not a constant import?
64
+ * The pre-v0.4 `DEFAULT_PANORAMA_SETTINGS` was a `let` mutated at
65
+ * module load — side-effect-heavy, untestable. v0.4 keeps the
66
+ * defaults static + side-effect-free; the device adaptation lives
67
+ * exactly where it needs to (Camera's mount-time `useState`).
68
+ */
69
+ export declare function buildPanoramaInitialSettings(overrides: PanoramaPropOverrides, isLowMemDevice: boolean): PanoramaSettings;
70
+ //# sourceMappingURL=buildPanoramaInitialSettings.d.ts.map
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * buildPanoramaInitialSettings — pure helper that materialises the
5
+ * initial `PanoramaSettings` snapshot from <Camera>'s `default*` props
6
+ * and a device-capability hint.
7
+ *
8
+ * Why a separate file?
9
+ * ────────────────────
10
+ *
11
+ * The settings tree lives in `PanoramaSettings.ts`; <Camera> consumes
12
+ * it and writes it into React state. The translation FROM the prop
13
+ * surface (flat names like `defaultStitchMode`) INTO the hierarchical
14
+ * settings tree is the part that:
15
+ *
16
+ * • is non-trivial enough to deserve direct unit-test coverage
17
+ * (covers the prop→sub-tree path mapping, which is easy to drift),
18
+ * • is pure TS — no React, no React Native — so the test runs in
19
+ * jest's `node` environment without needing the `react-native`
20
+ * preset (the rest of <Camera> is unmockable in pure TS).
21
+ *
22
+ * Living alongside `Camera.tsx` (vs. burying it as a private function
23
+ * inside) is the only way to get those two properties without taking
24
+ * on full React-Native jest setup just for this one helper.
25
+ *
26
+ * The exported `PanoramaPropOverrides` type is the prop-fragment
27
+ * <Camera> uses; `CameraProps` extends it. Keeping it explicit here
28
+ * means future Camera prop additions don't accidentally widen the
29
+ * settings-translation surface — every consumer of the helper sees
30
+ * exactly the prop fields that drive the settings tree.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.buildPanoramaInitialSettings = buildPanoramaInitialSettings;
34
+ const PanoramaSettings_1 = require("./PanoramaSettings");
35
+ /**
36
+ * Whether this device is low-memory enough to benefit from the
37
+ * feather+skip blender/seam fallback (vs. the heavier multiband+
38
+ * graphcut default). <Camera> derives this from
39
+ * `NativeModules.BatchStitcher.physicalMemoryBytes` at module load
40
+ * (RN-only — see `getIsLowMemDevice` in Camera.tsx); tests pass
41
+ * `false` explicitly to keep the prop-translation path the unit of
42
+ * the unit test.
43
+ *
44
+ * Why a parameter and not a constant import?
45
+ * The pre-v0.4 `DEFAULT_PANORAMA_SETTINGS` was a `let` mutated at
46
+ * module load — side-effect-heavy, untestable. v0.4 keeps the
47
+ * defaults static + side-effect-free; the device adaptation lives
48
+ * exactly where it needs to (Camera's mount-time `useState`).
49
+ */
50
+ function buildPanoramaInitialSettings(overrides, isLowMemDevice) {
51
+ // Start from the static, side-effect-free defaults.
52
+ const base = PanoramaSettings_1.DEFAULT_PANORAMA_SETTINGS;
53
+ // Apply the low-memory device adaptation:
54
+ // - feather blender (streams warped frames, no peak-memory spike)
55
+ // - skip seam finder (no graphcut working set)
56
+ // Replaces the v0.3 module-load-time mutation; same semantics.
57
+ const stitcherDefaults = isLowMemDevice
58
+ ? {
59
+ ...base.stitcher,
60
+ blenderType: 'feather',
61
+ seamFinderType: 'skip',
62
+ }
63
+ : base.stitcher;
64
+ // Use the standalone DEFAULT_FLOW_GATE_SETTINGS constant rather
65
+ // than `base.frameSelection.flow!` — the non-null assertion would
66
+ // crash silently if a future refactor un-defines the default's
67
+ // flow sub-tree, but the constant lives at the same level as the
68
+ // type and is type-checked. See F10 Phase 2 review (NIT-4).
69
+ const flowDefaults = PanoramaSettings_1.DEFAULT_FLOW_GATE_SETTINGS;
70
+ return {
71
+ captureSource: overrides.defaultCaptureSource ?? base.captureSource,
72
+ debug: base.debug,
73
+ stitcher: {
74
+ ...stitcherDefaults,
75
+ stitchMode: overrides.defaultStitchMode ?? stitcherDefaults.stitchMode,
76
+ warperType: overrides.defaultWarper ?? stitcherDefaults.warperType,
77
+ blenderType: overrides.defaultBlender ?? stitcherDefaults.blenderType,
78
+ seamFinderType: overrides.defaultSeamFinder ?? stitcherDefaults.seamFinderType,
79
+ },
80
+ frameSelection: {
81
+ ...base.frameSelection,
82
+ maxKeyframes: overrides.defaultKeyframeMaxCount ?? base.frameSelection.maxKeyframes,
83
+ overlapThreshold: overrides.defaultKeyframeOverlapThreshold
84
+ ?? base.frameSelection.overlapThreshold,
85
+ flow: {
86
+ ...flowDefaults,
87
+ noveltyPercentile: overrides.defaultFlowNoveltyPercentile
88
+ ?? flowDefaults.noveltyPercentile,
89
+ evalEveryNFrames: overrides.defaultFlowEvalEveryNFrames
90
+ ?? flowDefaults.evalEveryNFrames,
91
+ maxTranslationCm: overrides.defaultFlowMaxTranslationCm
92
+ ?? flowDefaults.maxTranslationCm,
93
+ },
94
+ },
95
+ };
96
+ }
97
+ //# sourceMappingURL=buildPanoramaInitialSettings.js.map
@@ -0,0 +1,24 @@
1
+ /** 2 GB in bytes — the cutoff below which `<Camera>` falls back
2
+ * to the feather+skip blender/seam combo for safer peak memory. */
3
+ export declare const LOW_MEM_THRESHOLD_BYTES: number;
4
+ /**
5
+ * Pure classifier. Returns `true` when `bytes` is a positive
6
+ * number strictly below the threshold. Zero / NaN / undefined-shaped
7
+ * inputs return `false` — the safe choice when the native bridge
8
+ * hasn't surfaced the value (assume the device has enough memory
9
+ * for the higher-quality combo; the operator can still flip
10
+ * blender / seamFinder in the modal).
11
+ */
12
+ export declare function isBelowMemThreshold(bytes: number): boolean;
13
+ /**
14
+ * Read the device's physical memory from the native bridge.
15
+ * Returns 0 when the bridge isn't loaded or the constant is missing
16
+ * — caller should treat 0 as "unknown".
17
+ */
18
+ export declare function getPhysicalMemoryBytes(): number;
19
+ /**
20
+ * Composed `getPhysicalMemoryBytes()` + `isBelowMemThreshold()`.
21
+ * Convenience for the common consumer pattern.
22
+ */
23
+ export declare function isLowMemDevice(): boolean;
24
+ //# sourceMappingURL=lowMemDevice.d.ts.map
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOW_MEM_THRESHOLD_BYTES = void 0;
4
+ exports.isBelowMemThreshold = isBelowMemThreshold;
5
+ exports.getPhysicalMemoryBytes = getPhysicalMemoryBytes;
6
+ exports.isLowMemDevice = isLowMemDevice;
7
+ // SPDX-License-Identifier: Apache-2.0
8
+ /**
9
+ * lowMemDevice — shared helpers around the iOS BatchStitcher
10
+ * `physicalMemoryBytes` constant.
11
+ *
12
+ * Two consumers (Camera.tsx's `useState` initialiser + the modal's
13
+ * device-mem debug line) had near-identical implementations of the
14
+ * same "read physical memory from NativeModules, classify as
15
+ * low-mem" logic. The F10 Phase 2 review (N2) flagged this as a
16
+ * drift hazard — exactly the kind of subtle duplication that audit
17
+ * fix F1 chased on the native side.
18
+ *
19
+ * Layered for testability:
20
+ *
21
+ * - `isBelowMemThreshold(bytes)` is pure (in: number, out:
22
+ * boolean) — unit-testable.
23
+ * - `getPhysicalMemoryBytes()` reads the RN bridge module — must
24
+ * run on a real device.
25
+ * - `isLowMemDevice()` composes the two for the common case.
26
+ *
27
+ * The 2 GB threshold corresponds to iPhone X / 8 Plus / iPhone 6s
28
+ * era devices; below that, multiband blend + graphcut seam-finder
29
+ * peak memory risks the jetsam threshold mid-stitch. Static value
30
+ * (no runtime config); revisit if the SDK ever targets a wider
31
+ * device range.
32
+ */
33
+ const react_native_1 = require("react-native");
34
+ /** 2 GB in bytes — the cutoff below which `<Camera>` falls back
35
+ * to the feather+skip blender/seam combo for safer peak memory. */
36
+ exports.LOW_MEM_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
37
+ /**
38
+ * Pure classifier. Returns `true` when `bytes` is a positive
39
+ * number strictly below the threshold. Zero / NaN / undefined-shaped
40
+ * inputs return `false` — the safe choice when the native bridge
41
+ * hasn't surfaced the value (assume the device has enough memory
42
+ * for the higher-quality combo; the operator can still flip
43
+ * blender / seamFinder in the modal).
44
+ */
45
+ function isBelowMemThreshold(bytes) {
46
+ return Number.isFinite(bytes)
47
+ && bytes > 0
48
+ && bytes < exports.LOW_MEM_THRESHOLD_BYTES;
49
+ }
50
+ /**
51
+ * Read the device's physical memory from the native bridge.
52
+ * Returns 0 when the bridge isn't loaded or the constant is missing
53
+ * — caller should treat 0 as "unknown".
54
+ */
55
+ function getPhysicalMemoryBytes() {
56
+ const m = react_native_1.NativeModules.BatchStitcher;
57
+ const bytes = m && typeof m === 'object'
58
+ ? m.physicalMemoryBytes
59
+ : undefined;
60
+ return typeof bytes === 'number' ? bytes : 0;
61
+ }
62
+ /**
63
+ * Composed `getPhysicalMemoryBytes()` + `isBelowMemThreshold()`.
64
+ * Convenience for the common consumer pattern.
65
+ */
66
+ function isLowMemDevice() {
67
+ return isBelowMemThreshold(getPhysicalMemoryBytes());
68
+ }
69
+ //# sourceMappingURL=lowMemDevice.js.map
package/dist/index.d.ts CHANGED
@@ -50,8 +50,12 @@ export type { CaptureThumbnailItem } from './camera/CaptureThumbnailStrip';
50
50
  export { IncrementalPanGuide } from './camera/IncrementalPanGuide';
51
51
  export { PanoramaBandOverlay } from './camera/PanoramaBandOverlay';
52
52
  export { PanoramaGuidance } from './camera/PanoramaGuidance';
53
- export { PanoramaSettingsModal, DEFAULT_PANORAMA_SETTINGS, } from './camera/PanoramaSettingsModal';
54
- export type { PanoramaSettings } from './camera/PanoramaSettingsModal';
53
+ export { PanoramaSettingsModal } from './camera/PanoramaSettingsModal';
54
+ export type { PanoramaSettingsModalProps } from './camera/PanoramaSettingsModal';
55
+ export { DEFAULT_PANORAMA_SETTINGS, DEFAULT_FLOW_GATE_SETTINGS, DEFAULT_SLITSCAN_SETTINGS, DEFAULT_HYBRID_SETTINGS, } from './camera/PanoramaSettings';
56
+ export type { CaptureBaseSettings, PanoramaSettings, BatchStitcherSettings, FrameSelectionSettings, FlowGateSettings, SlitscanSettings, SlitscanPaintingSettings, SlitscanRegistrationSettings, SlitscanAdvancedSettings, Ncc1dSettings, Ncc2dSettings, PlaneProjectionSettings, HybridSettings, } from './camera/PanoramaSettings';
57
+ export { panoramaSettingsToNativeConfig, slitscanSettingsToNativeConfig, hybridSettingsToNativeConfig, } from './camera/PanoramaSettingsBridge';
58
+ export type { NativeConfigDict } from './camera/PanoramaSettingsBridge';
55
59
  export { ViewportCropOverlay } from './camera/ViewportCropOverlay';
56
60
  export { useCapture } from './camera/useCapture';
57
61
  export type { TakePhotoCallOptions } from './camera/useCapture';
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * adds RetaiLens-specific features on top.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.stitchVideo = exports.useIncrementalJSDriver = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
25
+ exports.stitchVideo = exports.useIncrementalJSDriver = exports.useIncrementalStitcher = exports.cleanupOldKeyframes = exports.getIncrementalNativeModule = exports.subscribeIncrementalState = exports.incrementalStitcherIsAvailable = exports.IncrementalOutcome = exports.useDeviceOrientation = exports.useVideoCapture = exports.useCapture = exports.ViewportCropOverlay = exports.hybridSettingsToNativeConfig = exports.slitscanSettingsToNativeConfig = exports.panoramaSettingsToNativeConfig = exports.DEFAULT_HYBRID_SETTINGS = exports.DEFAULT_SLITSCAN_SETTINGS = exports.DEFAULT_FLOW_GATE_SETTINGS = exports.DEFAULT_PANORAMA_SETTINGS = exports.PanoramaSettingsModal = exports.PanoramaGuidance = exports.PanoramaBandOverlay = exports.IncrementalPanGuide = exports.CaptureThumbnailStrip = exports.useStitchStatsToast = exports.CaptureStitchStatsToast = exports.CaptureOrientationPill = exports.CaptureKeyframePill = exports.CaptureMemoryPill = exports.CaptureDebugOverlay = exports.CaptureStatusOverlay = exports.CapturePreview = exports.CaptureControlsBar = exports.CaptureHeader = exports.CameraView = exports.ARCameraView = exports.useIMUTranslationGate = exports.ARTrackingState = exports.useARSession = exports.CameraError = exports.Camera = void 0;
26
26
  // ─────────────────────────────────────────────────────────────────────
27
27
  // Layer 1 — the high-level <Camera> component
28
28
  // ─────────────────────────────────────────────────────────────────────
@@ -91,9 +91,30 @@ var PanoramaBandOverlay_1 = require("./camera/PanoramaBandOverlay");
91
91
  Object.defineProperty(exports, "PanoramaBandOverlay", { enumerable: true, get: function () { return PanoramaBandOverlay_1.PanoramaBandOverlay; } });
92
92
  var PanoramaGuidance_1 = require("./camera/PanoramaGuidance");
93
93
  Object.defineProperty(exports, "PanoramaGuidance", { enumerable: true, get: function () { return PanoramaGuidance_1.PanoramaGuidance; } });
94
+ // Settings modal — the modal is in `PanoramaSettingsModal.tsx`, but
95
+ // the type tree + defaults + JS↔native bridge live in dedicated
96
+ // files since v0.4 (F10). The modal is now a thin presentational
97
+ // component over the typed structure.
94
98
  var PanoramaSettingsModal_1 = require("./camera/PanoramaSettingsModal");
95
99
  Object.defineProperty(exports, "PanoramaSettingsModal", { enumerable: true, get: function () { return PanoramaSettingsModal_1.PanoramaSettingsModal; } });
96
- Object.defineProperty(exports, "DEFAULT_PANORAMA_SETTINGS", { enumerable: true, get: function () { return PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS; } });
100
+ // Settings types the v0.4 engine-discriminated structures. Three
101
+ // disjoint top-level types (one per stitching engine), each composed
102
+ // of named sub-trees the corresponding native engine actually reads.
103
+ // See `./camera/PanoramaSettings.ts` for the rationale and the
104
+ // field-by-field native-consumer references.
105
+ var PanoramaSettings_1 = require("./camera/PanoramaSettings");
106
+ Object.defineProperty(exports, "DEFAULT_PANORAMA_SETTINGS", { enumerable: true, get: function () { return PanoramaSettings_1.DEFAULT_PANORAMA_SETTINGS; } });
107
+ Object.defineProperty(exports, "DEFAULT_FLOW_GATE_SETTINGS", { enumerable: true, get: function () { return PanoramaSettings_1.DEFAULT_FLOW_GATE_SETTINGS; } });
108
+ Object.defineProperty(exports, "DEFAULT_SLITSCAN_SETTINGS", { enumerable: true, get: function () { return PanoramaSettings_1.DEFAULT_SLITSCAN_SETTINGS; } });
109
+ Object.defineProperty(exports, "DEFAULT_HYBRID_SETTINGS", { enumerable: true, get: function () { return PanoramaSettings_1.DEFAULT_HYBRID_SETTINGS; } });
110
+ // Settings → native config adapters. Layer 2 hosts building their
111
+ // own capture flow on top of `incremental.start()` should always
112
+ // pass the result of the matching adapter as `config`; the bridge is
113
+ // the single source of truth for the JS↔native wire format.
114
+ var PanoramaSettingsBridge_1 = require("./camera/PanoramaSettingsBridge");
115
+ Object.defineProperty(exports, "panoramaSettingsToNativeConfig", { enumerable: true, get: function () { return PanoramaSettingsBridge_1.panoramaSettingsToNativeConfig; } });
116
+ Object.defineProperty(exports, "slitscanSettingsToNativeConfig", { enumerable: true, get: function () { return PanoramaSettingsBridge_1.slitscanSettingsToNativeConfig; } });
117
+ Object.defineProperty(exports, "hybridSettingsToNativeConfig", { enumerable: true, get: function () { return PanoramaSettingsBridge_1.hybridSettingsToNativeConfig; } });
97
118
  var ViewportCropOverlay_1 = require("./camera/ViewportCropOverlay");
98
119
  Object.defineProperty(exports, "ViewportCropOverlay", { enumerable: true, get: function () { return ViewportCropOverlay_1.ViewportCropOverlay; } });
99
120
  // ── Capture hooks ─────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,8 +25,9 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "build": "tsc",
28
+ "build": "tsc -p tsconfig.build.json",
29
29
  "typecheck": "tsc --noEmit",
30
+ "test": "jest",
30
31
  "clean": "rm -rf dist",
31
32
  "postinstall": "node scripts/postinstall-fetch-binaries.js"
32
33
  },
@@ -52,13 +53,16 @@
52
53
  },
53
54
  "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
54
55
  "devDependencies": {
56
+ "@types/jest": "^29.5.0",
55
57
  "@types/react": "^19.0.0",
58
+ "jest": "^29.7.0",
56
59
  "react": "^19.0.0",
57
60
  "react-native": "^0.84.0",
58
61
  "react-native-safe-area-context": "^4.0.0",
59
62
  "react-native-sensors": "^7.0.0",
60
63
  "react-native-vision-camera": "^4.0.0",
61
64
  "rxjs": "^7.0.0",
65
+ "ts-jest": "^29.1.0",
62
66
  "typescript": "^5.5.0"
63
67
  },
64
68
  "peerDependencies": {
@@ -69,11 +69,14 @@ import { CaptureKeyframePill } from './CaptureKeyframePill';
69
69
  import { CaptureOrientationPill } from './CaptureOrientationPill';
70
70
  import { CaptureStitchStatsToast, useStitchStatsToast } from './CaptureStitchStatsToast';
71
71
  import { PanoramaBandOverlay } from './PanoramaBandOverlay';
72
+ import { type PanoramaSettings } from './PanoramaSettings';
73
+ import { panoramaSettingsToNativeConfig } from './PanoramaSettingsBridge';
74
+ import { PanoramaSettingsModal } from './PanoramaSettingsModal';
72
75
  import {
73
- DEFAULT_PANORAMA_SETTINGS,
74
- PanoramaSettingsModal,
75
- type PanoramaSettings,
76
- } from './PanoramaSettingsModal';
76
+ buildPanoramaInitialSettings,
77
+ type PanoramaPropOverrides,
78
+ } from './buildPanoramaInitialSettings';
79
+ import { isLowMemDevice } from './lowMemDevice';
77
80
  import { useCapture } from './useCapture';
78
81
  import { useDeviceOrientation } from './useDeviceOrientation';
79
82
  import {
@@ -471,40 +474,31 @@ function deriveEffectiveCaptureSource(
471
474
 
472
475
 
473
476
  /**
474
- * Apply per-prop defaults to build the initial settings snapshot.
475
- * The settings live in component state from there; the prop values
476
- * never re-flow.
477
+ * Pluck the props that influence the initial PanoramaSettings tree.
478
+ * Kept inline (vs. a wide structural type) so future Camera prop
479
+ * additions don't accidentally widen the settings-translation
480
+ * surface — the pure builder in `./buildPanoramaInitialSettings.ts`
481
+ * has the canonical interface; this just forwards the relevant
482
+ * fields.
477
483
  *
478
- * Note: the `default*ResolMP` props don't have a home on PanoramaSettings
479
- * yet they're accepted on the prop interface for forward compatibility
480
- * but ignored here. Wiring is a follow-up once PanoramaSettings is
481
- * extended.
484
+ * The `default*ResolMP` props on `CameraProps` are documented as
485
+ * forward-looking no-ops; the new PanoramaSettings tree has no home
486
+ * for them yet (the v0.3 audit found cv::Stitcher's resol knobs
487
+ * aren't reached by either platform's bridge). They're accepted on
488
+ * the prop interface for API stability and ignored here.
482
489
  */
483
- function buildInitialSettings(props: CameraProps): PanoramaSettings {
490
+ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
484
491
  return {
485
- ...DEFAULT_PANORAMA_SETTINGS,
486
- stitchMode: props.defaultStitchMode ?? DEFAULT_PANORAMA_SETTINGS.stitchMode,
487
- blenderType:
488
- props.defaultBlender ?? DEFAULT_PANORAMA_SETTINGS.blenderType,
489
- seamFinderType:
490
- props.defaultSeamFinder ?? DEFAULT_PANORAMA_SETTINGS.seamFinderType,
491
- warperType:
492
- props.defaultWarper ?? DEFAULT_PANORAMA_SETTINGS.warperType,
493
- flowNoveltyPercentile:
494
- props.defaultFlowNoveltyPercentile ??
495
- DEFAULT_PANORAMA_SETTINGS.flowNoveltyPercentile,
496
- flowEvalEveryNFrames:
497
- props.defaultFlowEvalEveryNFrames ??
498
- DEFAULT_PANORAMA_SETTINGS.flowEvalEveryNFrames,
499
- flowMaxTranslationCm:
500
- props.defaultFlowMaxTranslationCm ??
501
- DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm,
502
- keyframeMaxCount:
503
- props.defaultKeyframeMaxCount ??
504
- DEFAULT_PANORAMA_SETTINGS.keyframeMaxCount,
505
- keyframeOverlapThreshold:
506
- props.defaultKeyframeOverlapThreshold ??
507
- DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
492
+ defaultCaptureSource: props.defaultCaptureSource,
493
+ defaultStitchMode: props.defaultStitchMode,
494
+ defaultBlender: props.defaultBlender,
495
+ defaultSeamFinder: props.defaultSeamFinder,
496
+ defaultWarper: props.defaultWarper,
497
+ defaultFlowNoveltyPercentile: props.defaultFlowNoveltyPercentile,
498
+ defaultFlowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames,
499
+ defaultFlowMaxTranslationCm: props.defaultFlowMaxTranslationCm,
500
+ defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
501
+ defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
508
502
  };
509
503
  }
510
504
 
@@ -546,7 +540,10 @@ export function Camera(props: CameraProps): React.JSX.Element {
546
540
  );
547
541
  const [lens, setLens] = useState<CameraLens>(defaultLens);
548
542
  const [settings, setSettings] = useState<PanoramaSettings>(() =>
549
- buildInitialSettings(props),
543
+ buildPanoramaInitialSettings(
544
+ extractPanoramaOverrides(props),
545
+ isLowMemDevice(),
546
+ ),
550
547
  );
551
548
  const [settingsModalVisible, setSettingsModalVisible] = useState(false);
552
549
  const [statusPhase, setStatusPhase] = useState<CaptureStatusPhase>('idle');
@@ -694,12 +691,18 @@ export function Camera(props: CameraProps): React.JSX.Element {
694
691
  // |residual|` — which undercounted any time a non-IMU accept
695
692
  // (flow novelty, force-last) reset the integrator before the
696
693
  // budget threshold was reached.
694
+ // The translation budget lives at `frameSelection.flow.maxTranslationCm`
695
+ // in the new hierarchical settings shape. When `flow` is undefined
696
+ // (the consumer opted out of the flow strategy entirely), the gate
697
+ // stays disabled — same observable behaviour as v0.3's `0` default.
698
+ const flowMaxTranslationCm =
699
+ settings.frameSelection.flow?.maxTranslationCm ?? 0;
697
700
  const imuGate = useIMUTranslationGate({
698
701
  enabled:
699
702
  isNonAR
700
703
  && statusPhase === 'recording'
701
- && settings.flowMaxTranslationCm > 0,
702
- budgetMeters: Math.max(0.001, settings.flowMaxTranslationCm / 100.0),
704
+ && flowMaxTranslationCm > 0,
705
+ budgetMeters: Math.max(0.001, flowMaxTranslationCm / 100.0),
703
706
  onBudgetExceeded: () => {
704
707
  const mod = getIncrementalNativeModule();
705
708
  mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
@@ -890,6 +893,25 @@ export function Camera(props: CameraProps): React.JSX.Element {
890
893
  deviceOrientation === 'portrait' ? 90
891
894
  : deviceOrientation === 'portrait-upside-down' ? 270
892
895
  : 0;
896
+ // v0.4 — the inline-flat config dict that v0.3 maintained here
897
+ // moved into `panoramaSettingsToNativeConfig` (see
898
+ // PanoramaSettingsBridge.ts). That adapter is the single source
899
+ // of truth for the JS→native wire format; both this call site
900
+ // AND the modal's reset-to-defaults preview agree on the same
901
+ // mapping. Audit fixes F1 / F4 / F6 from v0.3 are now properties
902
+ // of the bridge (verified by the unit tests in
903
+ // src/camera/__tests__/PanoramaSettingsBridge.test.ts).
904
+ //
905
+ // 2026-05-23 — override `captureSource` with the runtime-derived
906
+ // `effectiveCaptureSource` (from `arPreference + lens +
907
+ // AR-device-support`). Pre-this change the camera-screen AR
908
+ // toggle wrote ONLY to local `arPreference` state while the
909
+ // bridge read `settings.captureSource` — so native could think
910
+ // the capture was AR while the operator had toggled it off (or
911
+ // vice-versa). Single source of truth now: whatever camera the
912
+ // operator can see is what native is told it is. The settings
913
+ // modal's `captureSource` control has been removed for the same
914
+ // reason — see PanoramaSettingsModal.tsx for the rationale.
893
915
  await incremental.start({
894
916
  snapshotJpegQuality: 75,
895
917
  snapshotEveryNAccepts: 1,
@@ -901,37 +923,10 @@ export function Camera(props: CameraProps): React.JSX.Element {
901
923
  canvasWidth: 5000,
902
924
  canvasHeight: 5000,
903
925
  engine: 'batch-keyframe',
904
- config: {
905
- // ── cv::Stitcher (batch finalize) ─────────────────────────
906
- stitchMode: settings.stitchMode,
907
- warperType: settings.warperType,
908
- blenderType: settings.blenderType,
909
- seamFinderType: settings.seamFinderType,
910
- enableMaxInscribedRectCrop: settings.enableMaxInscribedRectCrop,
911
- // ── KeyframeGate (per-frame selection) ────────────────────
912
- // F6 audit fix: pass settings.frameSelectionMode through
913
- // instead of hardcoding 'flow-based' (which silently made the
914
- // time-based / pose-based modal options no-ops).
915
- frameSelectionMode: settings.frameSelectionMode,
916
- keyframeMaxCount: settings.keyframeMaxCount,
917
- keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
918
- // ── Flow-strategy tunables ────────────────────────────────
919
- // F4 audit fix: previously omitted, which made the modal
920
- // sliders for these three a complete no-op (only iOS native
921
- // even read them, and only when JS sent them).
922
- flowNoveltyPercentile: settings.flowNoveltyPercentile,
923
- flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
924
- flowMaxTranslationCm: settings.flowMaxTranslationCm,
925
- flowMaxCorners: settings.flowMaxCorners,
926
- flowQualityLevel: settings.flowQualityLevel,
927
- flowMinDistance: settings.flowMinDistance,
928
- // ── Engine-routing flags consumed by native ───────────────
929
- // F1 audit fix: Android keyframe gate's disableAngularFallback
930
- // opt-out reads this to decide whether to skip the angular
931
- // fallback (gyro pose is too noisy for the FoV-overlap calc
932
- // in non-AR mode, causing degenerate cv::Stitcher params).
933
- captureSource: settings.captureSource,
934
- },
926
+ config: panoramaSettingsToNativeConfig({
927
+ ...settings,
928
+ captureSource: effectiveCaptureSource,
929
+ }),
935
930
  });
936
931
  imuGate.resetAnchor();
937
932
  // Start pumping vision-camera snapshots into the engine for
@@ -959,6 +954,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
959
954
  isNonAR,
960
955
  deviceOrientation,
961
956
  settings,
957
+ effectiveCaptureSource,
962
958
  imuGate,
963
959
  jsDriver,
964
960
  onError,
@@ -1048,6 +1044,18 @@ export function Camera(props: CameraProps): React.JSX.Element {
1048
1044
  onError,
1049
1045
  recordingStartedAt,
1050
1046
  jsDriver,
1047
+ // F10 Phase 2 review N1 — these four were missing pre-fix. The
1048
+ // callback reads `settings.debug` (to gate the stitchToast),
1049
+ // `isNonAR` (to decide whether to read IMU totalAbs translation),
1050
+ // `imuGate` (the read itself), and `stitchToast` (the toast hook
1051
+ // object). If any of those identities change between the user
1052
+ // pressing-and-holding the shutter and the release, the stale-
1053
+ // closure read could disagree with the actual current state.
1054
+ // Pre-existing v0.3 bug; v0.4 was the natural time to address it.
1055
+ settings,
1056
+ isNonAR,
1057
+ imuGate,
1058
+ stitchToast,
1051
1059
  ]);
1052
1060
 
1053
1061
  // ── Lens / AR-toggle handlers ───────────────────────────────────
@@ -1132,8 +1140,8 @@ export function Camera(props: CameraProps): React.JSX.Element {
1132
1140
  isNonAR ? imuGate.getTranslationMetres() : null
1133
1141
  }
1134
1142
  captureSource={effectiveCaptureSource}
1135
- frameSelectionMode={settings.frameSelectionMode}
1136
- stitchMode={settings.stitchMode}
1143
+ frameSelectionMode={settings.frameSelection.mode}
1144
+ stitchMode={settings.stitcher.stitchMode}
1137
1145
  />
1138
1146
  </>
1139
1147
  )}