react-native-image-stitcher 0.9.0 → 0.10.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 +150 -0
- package/android/build.gradle +10 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
- package/cpp/stitcher_worklet_registry.cpp +10 -0
- package/cpp/stitcher_worklet_registry.hpp +10 -0
- package/cpp/tests/CMakeLists.txt +98 -0
- package/cpp/tests/README.md +86 -0
- package/cpp/tests/pose_test.cpp +74 -0
- package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
- package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
- package/cpp/tests/stubs/jsi/jsi.h +33 -0
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
- package/dist/camera/useCapture.d.ts +1 -1
- package/dist/camera/useCapture.js +1 -1
- package/dist/stitching/incremental.d.ts +41 -0
- package/dist/stitching/useFrameStream.js +52 -37
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
- package/package.json +1 -1
- package/src/camera/useCapture.ts +1 -1
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
- package/src/stitching/incremental.ts +42 -0
- package/src/stitching/useFrameStream.ts +55 -39
|
@@ -1863,12 +1863,27 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1863
1863
|
config: [String: Any],
|
|
1864
1864
|
completion: @escaping ([String: Any]?, NSError?) -> Void
|
|
1865
1865
|
) {
|
|
1866
|
+
// v0.10.0 #15A — emit `validating` at the very top so JS sees
|
|
1867
|
+
// refine activity even when validation fails fast. Frames may
|
|
1868
|
+
// be empty here; report whatever the caller asked for.
|
|
1869
|
+
emitRefineProgress(
|
|
1870
|
+
stage: "validating",
|
|
1871
|
+
fraction: 0.05,
|
|
1872
|
+
frames: framePaths.count,
|
|
1873
|
+
errorMessage: nil
|
|
1874
|
+
)
|
|
1866
1875
|
guard framePaths.count >= 2 else {
|
|
1876
|
+
let msg = "refinePanorama requires at least 2 framePaths (got \(framePaths.count))."
|
|
1877
|
+
emitRefineProgress(
|
|
1878
|
+
stage: "error",
|
|
1879
|
+
fraction: 1.0,
|
|
1880
|
+
frames: framePaths.count,
|
|
1881
|
+
errorMessage: msg
|
|
1882
|
+
)
|
|
1867
1883
|
completion(nil, NSError(
|
|
1868
1884
|
domain: "RNImageStitcherIncremental",
|
|
1869
1885
|
code: 9101,
|
|
1870
|
-
userInfo: [NSLocalizedDescriptionKey:
|
|
1871
|
-
"refinePanorama requires at least 2 framePaths (got \(framePaths.count))."]
|
|
1886
|
+
userInfo: [NSLocalizedDescriptionKey: msg]
|
|
1872
1887
|
))
|
|
1873
1888
|
return
|
|
1874
1889
|
}
|
|
@@ -1876,11 +1891,17 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1876
1891
|
for p in framePaths {
|
|
1877
1892
|
let cleaned = p.hasPrefix("file://") ? String(p.dropFirst(7)) : p
|
|
1878
1893
|
if !fm.fileExists(atPath: cleaned) {
|
|
1894
|
+
let msg = "refinePanorama: keyframe missing on disk — \(cleaned)"
|
|
1895
|
+
emitRefineProgress(
|
|
1896
|
+
stage: "error",
|
|
1897
|
+
fraction: 1.0,
|
|
1898
|
+
frames: framePaths.count,
|
|
1899
|
+
errorMessage: msg
|
|
1900
|
+
)
|
|
1879
1901
|
completion(nil, NSError(
|
|
1880
1902
|
domain: "RNImageStitcherIncremental",
|
|
1881
1903
|
code: 9102,
|
|
1882
|
-
userInfo: [NSLocalizedDescriptionKey:
|
|
1883
|
-
"refinePanorama: keyframe missing on disk — \(cleaned)"]
|
|
1904
|
+
userInfo: [NSLocalizedDescriptionKey: msg]
|
|
1884
1905
|
))
|
|
1885
1906
|
return
|
|
1886
1907
|
}
|
|
@@ -1912,7 +1933,18 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1912
1933
|
cleanedOutput,
|
|
1913
1934
|
warper, blender, seam)
|
|
1914
1935
|
|
|
1915
|
-
|
|
1936
|
+
// v0.10.0 #15A — capture for the inner closure; `self` is
|
|
1937
|
+
// captured weakly so the progress emitter survives only as
|
|
1938
|
+
// long as the IncrementalStitcher itself does. An emitter
|
|
1939
|
+
// call on a torn instance is a no-op via `?.`.
|
|
1940
|
+
let frameCount = framePaths.count
|
|
1941
|
+
refineQueue.async { [weak self] in
|
|
1942
|
+
self?.emitRefineProgress(
|
|
1943
|
+
stage: "stitching",
|
|
1944
|
+
fraction: 0.1,
|
|
1945
|
+
frames: frameCount,
|
|
1946
|
+
errorMessage: nil
|
|
1947
|
+
)
|
|
1916
1948
|
// C2-style: closure captures only value-typed locals
|
|
1917
1949
|
// (paths, output path, config strings). No `self` access
|
|
1918
1950
|
// is needed for the cv::Stitcher call — OpenCVStitcher is
|
|
@@ -1935,24 +1967,53 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1935
1967
|
// OpenCVStitcher hit one of its six guarded failure
|
|
1936
1968
|
// returns; surface as a clean NSError.
|
|
1937
1969
|
if r.width == 0 && r.height == 0 {
|
|
1970
|
+
let msg = "refinePanorama: stitcher returned sentinel — see preceding [BatchStitcher] log for cause."
|
|
1971
|
+
self?.emitRefineProgress(
|
|
1972
|
+
stage: "error",
|
|
1973
|
+
fraction: 1.0,
|
|
1974
|
+
frames: frameCount,
|
|
1975
|
+
errorMessage: msg
|
|
1976
|
+
)
|
|
1938
1977
|
completion(nil, NSError(
|
|
1939
1978
|
domain: "RNImageStitcherIncremental",
|
|
1940
1979
|
code: 9107,
|
|
1941
|
-
userInfo: [NSLocalizedDescriptionKey:
|
|
1942
|
-
"refinePanorama: stitcher returned sentinel — see preceding [BatchStitcher] log for cause."]
|
|
1980
|
+
userInfo: [NSLocalizedDescriptionKey: msg]
|
|
1943
1981
|
))
|
|
1944
1982
|
return
|
|
1945
1983
|
}
|
|
1984
|
+
// Stitch succeeded — OpenCVStitcher writes the JPEG
|
|
1985
|
+
// internally before returning, so "writing" really
|
|
1986
|
+
// captures the final assembly + file I/O cost. Emit
|
|
1987
|
+
// here so JS can flip its label from "Stitching" to
|
|
1988
|
+
// "Writing" before the done event fires.
|
|
1989
|
+
self?.emitRefineProgress(
|
|
1990
|
+
stage: "writing",
|
|
1991
|
+
fraction: 0.9,
|
|
1992
|
+
frames: frameCount,
|
|
1993
|
+
errorMessage: nil
|
|
1994
|
+
)
|
|
1995
|
+
self?.emitRefineProgress(
|
|
1996
|
+
stage: "done",
|
|
1997
|
+
fraction: 1.0,
|
|
1998
|
+
frames: frameCount,
|
|
1999
|
+
errorMessage: nil
|
|
2000
|
+
)
|
|
1946
2001
|
completion([
|
|
1947
2002
|
"panoramaPath": r.outputPath,
|
|
1948
2003
|
"width": Int(r.width),
|
|
1949
2004
|
"height": Int(r.height),
|
|
1950
|
-
"framesRequested":
|
|
1951
|
-
"framesIncluded":
|
|
2005
|
+
"framesRequested": frameCount,
|
|
2006
|
+
"framesIncluded": frameCount,
|
|
1952
2007
|
"framesDropped": 0,
|
|
1953
2008
|
"finalConfidenceThresh": -1.0,
|
|
1954
2009
|
], nil)
|
|
1955
2010
|
} catch let err as NSError {
|
|
2011
|
+
self?.emitRefineProgress(
|
|
2012
|
+
stage: "error",
|
|
2013
|
+
fraction: 1.0,
|
|
2014
|
+
frames: frameCount,
|
|
2015
|
+
errorMessage: err.localizedDescription
|
|
2016
|
+
)
|
|
1956
2017
|
completion(nil, err)
|
|
1957
2018
|
}
|
|
1958
2019
|
}
|
|
@@ -2092,6 +2153,74 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2092
2153
|
)
|
|
2093
2154
|
}
|
|
2094
2155
|
|
|
2156
|
+
/// v0.10.0 #15A — emit a refine-pipeline phase update on the same
|
|
2157
|
+
/// `IncrementalStateUpdate` channel that carries `isRefining` /
|
|
2158
|
+
/// `refinedPanoramaPath`. Five `stage` values fire across the
|
|
2159
|
+
/// lifetime of one `refinePanorama` call:
|
|
2160
|
+
///
|
|
2161
|
+
/// - `validating` (fraction 0.05) — synchronous input checks
|
|
2162
|
+
/// - `stitching` (fraction 0.10) — start of the OpenCV stitch
|
|
2163
|
+
/// - `writing` (fraction 0.90) — stitch returned, JPEG written
|
|
2164
|
+
/// - `done` (fraction 1.00) — completion handler invoked
|
|
2165
|
+
/// - `error` (fraction 1.00) — failure path (`errorMessage`
|
|
2166
|
+
/// is non-nil)
|
|
2167
|
+
///
|
|
2168
|
+
/// `fraction` is intentionally coarse: OpenCV's `Stitcher` doesn't
|
|
2169
|
+
/// expose stage-by-stage callbacks, so the 0.10 → 0.90 jump is a
|
|
2170
|
+
/// single opaque step. JS uses the `stage` string for the UI
|
|
2171
|
+
/// label and `fraction` purely for spinner progress.
|
|
2172
|
+
///
|
|
2173
|
+
/// Reuses the existing channel (rather than introducing a new
|
|
2174
|
+
/// device-event name) so the JS subscriber doesn't need to wire
|
|
2175
|
+
/// a second listener. The payload carries the same skeleton
|
|
2176
|
+
/// `emitRefinementState` emits (lastState fields preserved) so
|
|
2177
|
+
/// `isRefining` / `refinedPanoramaPath` sticky-merge logic on the
|
|
2178
|
+
/// JS side keeps working untouched.
|
|
2179
|
+
private func emitRefineProgress(
|
|
2180
|
+
stage: String,
|
|
2181
|
+
fraction: Double,
|
|
2182
|
+
frames: Int?,
|
|
2183
|
+
errorMessage: String?
|
|
2184
|
+
) {
|
|
2185
|
+
// Disk-trail breadcrumb — every refine emit lands here so a
|
|
2186
|
+
// future regression can be diagnosed by pulling the bridge's
|
|
2187
|
+
// debug file without needing live Console.app access.
|
|
2188
|
+
IncrementalStitcher.fileLog(
|
|
2189
|
+
"[refine.progress] stage=\(stage) frac=\(fraction) frames=\(frames ?? -1) hasError=\(errorMessage != nil)"
|
|
2190
|
+
)
|
|
2191
|
+
stateLock.lock()
|
|
2192
|
+
let prev = self.lastState
|
|
2193
|
+
stateLock.unlock()
|
|
2194
|
+
let state = IncrementalStateObject(
|
|
2195
|
+
panoramaPath: prev?.panoramaPath,
|
|
2196
|
+
width: prev?.width ?? 0,
|
|
2197
|
+
height: prev?.height ?? 0,
|
|
2198
|
+
acceptedCount: prev?.acceptedCount ?? 0,
|
|
2199
|
+
outcome: prev?.outcome ?? .acceptedHigh,
|
|
2200
|
+
confidence: prev?.confidence ?? 1.0,
|
|
2201
|
+
overlapPercent: prev?.overlapPercent ?? -1.0,
|
|
2202
|
+
processingMs: 0,
|
|
2203
|
+
isLandscape: prev?.isLandscape ?? false,
|
|
2204
|
+
paintedExtent: prev?.paintedExtent ?? 0,
|
|
2205
|
+
panExtent: prev?.panExtent ?? 0,
|
|
2206
|
+
keyframeMax: prev?.keyframeMax ?? 0
|
|
2207
|
+
)
|
|
2208
|
+
var dict = state.asDictionary()
|
|
2209
|
+
dict["refineStage"] = stage
|
|
2210
|
+
dict["refineProgress"] = fraction
|
|
2211
|
+
if let f = frames {
|
|
2212
|
+
dict["refineFrames"] = f
|
|
2213
|
+
}
|
|
2214
|
+
if let e = errorMessage {
|
|
2215
|
+
dict["refineError"] = e
|
|
2216
|
+
}
|
|
2217
|
+
NotificationCenter.default.post(
|
|
2218
|
+
name: .retailensIncrementalStateUpdate,
|
|
2219
|
+
object: nil,
|
|
2220
|
+
userInfo: dict
|
|
2221
|
+
)
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2095
2224
|
/// Cancel an in-progress capture without producing output.
|
|
2096
2225
|
/// Same V12.1 synchronous-stop pattern as finalize.
|
|
2097
2226
|
@objc public func cancel() {
|
|
@@ -33,6 +33,18 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
|
|
|
33
33
|
|
|
34
34
|
public override init() {
|
|
35
35
|
super.init()
|
|
36
|
+
// Under RN bridgeless interop the bridge's init() can be
|
|
37
|
+
// invoked twice on the same instance (observed via identical
|
|
38
|
+
// instance pointers firing the observer selector twice per
|
|
39
|
+
// notification). Defensively remove any prior registration
|
|
40
|
+
// for this notification name before adding one, so the
|
|
41
|
+
// observer can only fire once per post regardless of how
|
|
42
|
+
// many times init runs.
|
|
43
|
+
NotificationCenter.default.removeObserver(
|
|
44
|
+
self,
|
|
45
|
+
name: .retailensIncrementalStateUpdate,
|
|
46
|
+
object: nil
|
|
47
|
+
)
|
|
36
48
|
// Subscribe once at construction. The handler self-checks
|
|
37
49
|
// `hasListeners` before forwarding, so we don't have to
|
|
38
50
|
// unsubscribe / resubscribe on every JS listener attach/detach.
|
|
@@ -58,9 +70,6 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
|
|
|
58
70
|
return [Self.stateUpdateEvent]
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
// (startObserving / stopObserving moved next to handleStateUpdate
|
|
62
|
-
// for the PiP investigation; remove this comment after.)
|
|
63
|
-
|
|
64
73
|
// MARK: - Module methods
|
|
65
74
|
|
|
66
75
|
/// `options` (all optional, sensible defaults documented in
|
|
@@ -477,26 +486,53 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
|
|
|
477
486
|
|
|
478
487
|
@objc private func handleStateUpdate(_ notification: Notification) {
|
|
479
488
|
let hasPath = (notification.userInfo?["panoramaPath"] != nil)
|
|
480
|
-
|
|
489
|
+
let refineStage = notification.userInfo?["refineStage"] as? String
|
|
490
|
+
if hasPath || refineStage != nil {
|
|
481
491
|
IncrementalStitcher.fileLog(
|
|
482
|
-
"bridge handleStateUpdate hasListeners=\(hasListeners) hasPath=\(hasPath) thread=\(Thread.isMainThread ? "main" : "bg")"
|
|
492
|
+
"bridge handleStateUpdate hasListeners=\(hasListeners) hasPath=\(hasPath) refineStage=\(refineStage ?? "nil") thread=\(Thread.isMainThread ? "main" : "bg")"
|
|
483
493
|
)
|
|
484
494
|
}
|
|
485
|
-
guard hasListeners else {
|
|
495
|
+
guard hasListeners else {
|
|
496
|
+
if let stage = refineStage {
|
|
497
|
+
IncrementalStitcher.fileLog(
|
|
498
|
+
"bridge handleStateUpdate DROPPED refineStage=\(stage) — hasListeners=false"
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
return
|
|
502
|
+
}
|
|
486
503
|
guard let userInfo = notification.userInfo else { return }
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
504
|
+
// We deliver via `bridge.enqueueJSCall("RCTDeviceEventEmitter", "emit", ...)`
|
|
505
|
+
// rather than `RCTEventEmitter.sendEvent(...)` because under RN
|
|
506
|
+
// bridgeless interop `sendEvent` silently no-ops for some
|
|
507
|
+
// event-body shapes even when `_bridge` is non-nil and
|
|
508
|
+
// `_listenerCount > 0` (confirmed via os_log instrumentation
|
|
509
|
+
// during v0.10.0 PR B development — refine events with
|
|
510
|
+
// refineStage/refineProgress/refineFrames were not reaching
|
|
511
|
+
// any JS subscriber while live state events with a smaller
|
|
512
|
+
// body shape on the same channel were). enqueueJSCall is
|
|
513
|
+
// the underlying mechanism sendEvent uses in Paper mode, so
|
|
514
|
+
// it is strictly at least as well-supported.
|
|
492
515
|
DispatchQueue.main.async { [weak self] in
|
|
493
516
|
guard let self = self else { return }
|
|
494
|
-
if hasPath {
|
|
517
|
+
if hasPath || refineStage != nil {
|
|
495
518
|
IncrementalStitcher.fileLog(
|
|
496
|
-
"bridge
|
|
519
|
+
"bridge enqueueJSCall (main queue) body.panoramaPath=\(userInfo["panoramaPath"] ?? "MISSING") refineStage=\(refineStage ?? "nil")"
|
|
497
520
|
)
|
|
498
521
|
}
|
|
499
|
-
self.
|
|
522
|
+
guard let bridge = self.bridge else {
|
|
523
|
+
if hasPath || refineStage != nil {
|
|
524
|
+
IncrementalStitcher.fileLog(
|
|
525
|
+
"bridge enqueueJSCall DROPPED — self.bridge is nil"
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
bridge.enqueueJSCall(
|
|
531
|
+
"RCTDeviceEventEmitter",
|
|
532
|
+
method: "emit",
|
|
533
|
+
args: [Self.stateUpdateEvent, userInfo],
|
|
534
|
+
completion: nil
|
|
535
|
+
)
|
|
500
536
|
}
|
|
501
537
|
}
|
|
502
538
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
package/src/camera/useCapture.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - This hook does NOT persist captures. Host apps hand the
|
|
19
19
|
* returned CaptureResult to their own storage layer (WatermelonDB
|
|
20
20
|
* insert, Redux dispatch, whatever).
|
|
21
|
-
* - Video recording lives in useVideoCapture
|
|
21
|
+
* - Video recording lives in useVideoCapture.
|
|
22
22
|
*
|
|
23
23
|
* The public API is designed to be minimal and replaceable: host apps
|
|
24
24
|
* that prefer the raw vision-camera API can opt out of this hook and
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit + integration coverage for the v0.10.0 PR B refine-progress
|
|
4
|
+
* lifecycle on the `IncrementalStateUpdate` channel.
|
|
5
|
+
*
|
|
6
|
+
* What this test is for:
|
|
7
|
+
*
|
|
8
|
+
* - Contract: native emits a 4-stage sequence
|
|
9
|
+
* `validating → stitching → writing → done` (and the failure
|
|
10
|
+
* variant `validating → error`) with `refineStage` /
|
|
11
|
+
* `refineProgress` / `refineFrames` / `refineError` keys.
|
|
12
|
+
* - Regression: catches a future renamer of any of those keys
|
|
13
|
+
* (subscribeIncrementalState would silently deliver `undefined`
|
|
14
|
+
* for the missing fields, and the host's progress pill would
|
|
15
|
+
* stop rendering — exactly the bug class we hit on iOS in PR B
|
|
16
|
+
* before the bridgeless-interop fix).
|
|
17
|
+
*
|
|
18
|
+
* What this test is NOT for:
|
|
19
|
+
*
|
|
20
|
+
* - Exercising the real native bridge — RCTEventEmitter under RN
|
|
21
|
+
* bridgeless interop can only be tested on-device. The bug we
|
|
22
|
+
* fixed in PR B (sendEvent silently no-ops for certain body
|
|
23
|
+
* shapes) is verified via the manual smoke test recorded in
|
|
24
|
+
* CHANGELOG.md "Fixed — v0.10.0 PR B (iOS)". This file pins
|
|
25
|
+
* the JS-side contract that bridge fix has to satisfy.
|
|
26
|
+
*
|
|
27
|
+
* Mock surface: per-test `jest.mock('react-native', ...)` so the
|
|
28
|
+
* shared `jest.mocks/react-native.js` stays minimal (per the comment
|
|
29
|
+
* in that file). We stub `NativeModules.IncrementalStitcher` and
|
|
30
|
+
* `NativeEventEmitter` together because `subscribeIncrementalState`
|
|
31
|
+
* wires them together internally.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { IncrementalState } from '../incremental';
|
|
35
|
+
|
|
36
|
+
// Hand-rolled event-emitter fake we can drive synchronously from
|
|
37
|
+
// tests. Modelled on RN's NativeEventEmitter shape: addListener
|
|
38
|
+
// returns an object with a `.remove()` method.
|
|
39
|
+
type Listener = (state: IncrementalState) => void;
|
|
40
|
+
|
|
41
|
+
class FakeNativeEventEmitter {
|
|
42
|
+
private listeners: Map<string, Set<Listener>> = new Map();
|
|
43
|
+
|
|
44
|
+
constructor(_nativeModule: unknown) {
|
|
45
|
+
// No-op: real RN reads addListener/removeListeners off the
|
|
46
|
+
// native module for the listener-count contract; we don't.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
addListener(eventType: string, listener: Listener) {
|
|
50
|
+
let set = this.listeners.get(eventType);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
this.listeners.set(eventType, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return {
|
|
57
|
+
remove: () => {
|
|
58
|
+
set!.delete(listener);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Test-only helper: drive an event into all subscribers.
|
|
64
|
+
_emit(eventType: string, state: IncrementalState) {
|
|
65
|
+
const set = this.listeners.get(eventType);
|
|
66
|
+
if (!set) return;
|
|
67
|
+
for (const listener of set) {
|
|
68
|
+
listener(state);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Shared emitter handle the per-test setup writes its asserts
|
|
74
|
+
// against. The mock factory below has to construct via `new`, so we
|
|
75
|
+
// stash the latest instance here for the test to drive.
|
|
76
|
+
let lastEmitter: FakeNativeEventEmitter | null = null;
|
|
77
|
+
|
|
78
|
+
jest.mock('react-native', () => ({
|
|
79
|
+
NativeModules: {
|
|
80
|
+
IncrementalStitcher: {
|
|
81
|
+
// RCTEventEmitter / NativeEventEmitter contract — RN's runtime
|
|
82
|
+
// calls these when JS subscribes / unsubscribes so the native
|
|
83
|
+
// side can track listener count. We just stub them.
|
|
84
|
+
addListener: jest.fn(),
|
|
85
|
+
removeListeners: jest.fn(),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
NativeEventEmitter: jest.fn().mockImplementation((nativeModule: unknown) => {
|
|
89
|
+
lastEmitter = new FakeNativeEventEmitter(nativeModule);
|
|
90
|
+
return lastEmitter;
|
|
91
|
+
}),
|
|
92
|
+
Platform: { OS: 'ios', select: (spec: { ios?: unknown; default?: unknown }) => spec.ios ?? spec.default },
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Import AFTER jest.mock so the SUT picks up the mocked module.
|
|
96
|
+
import { subscribeIncrementalState } from '../incremental';
|
|
97
|
+
|
|
98
|
+
// Build the base state shape the native side emits — matches the
|
|
99
|
+
// fields IncrementalStateObject.asDictionary() includes on iOS and
|
|
100
|
+
// Arguments.createMap() includes on Android.
|
|
101
|
+
function makeBaseState(): IncrementalState {
|
|
102
|
+
return {
|
|
103
|
+
width: 1920,
|
|
104
|
+
height: 1080,
|
|
105
|
+
acceptedCount: 3,
|
|
106
|
+
outcome: 8, // acceptedHigh
|
|
107
|
+
confidence: 0.92,
|
|
108
|
+
overlapPercent: 18.5,
|
|
109
|
+
processingMs: 0,
|
|
110
|
+
isLandscape: false,
|
|
111
|
+
paintedExtent: 1920,
|
|
112
|
+
panExtent: 1920,
|
|
113
|
+
keyframeMax: 0,
|
|
114
|
+
} as IncrementalState;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('subscribeIncrementalState — refine progress lifecycle (v0.10.0 PR B)', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
lastEmitter = null;
|
|
120
|
+
jest.clearAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns null when the native IncrementalStitcher module is missing', () => {
|
|
124
|
+
// Temporarily blank the module.
|
|
125
|
+
const RN = jest.requireMock('react-native') as { NativeModules: Record<string, unknown> };
|
|
126
|
+
const saved = RN.NativeModules.IncrementalStitcher;
|
|
127
|
+
RN.NativeModules.IncrementalStitcher = undefined;
|
|
128
|
+
try {
|
|
129
|
+
expect(subscribeIncrementalState(() => {})).toBeNull();
|
|
130
|
+
} finally {
|
|
131
|
+
RN.NativeModules.IncrementalStitcher = saved;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns an EmitterSubscription when subscribed; remove() stops delivery', () => {
|
|
136
|
+
const events: IncrementalState[] = [];
|
|
137
|
+
const sub = subscribeIncrementalState((s) => events.push(s));
|
|
138
|
+
expect(sub).not.toBeNull();
|
|
139
|
+
expect(lastEmitter).not.toBeNull();
|
|
140
|
+
|
|
141
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
142
|
+
...makeBaseState(),
|
|
143
|
+
refineStage: 'validating',
|
|
144
|
+
refineProgress: 0.05,
|
|
145
|
+
refineFrames: 3,
|
|
146
|
+
} as IncrementalState);
|
|
147
|
+
expect(events).toHaveLength(1);
|
|
148
|
+
|
|
149
|
+
sub!.remove();
|
|
150
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
151
|
+
...makeBaseState(),
|
|
152
|
+
refineStage: 'done',
|
|
153
|
+
refineProgress: 1.0,
|
|
154
|
+
refineFrames: 3,
|
|
155
|
+
} as IncrementalState);
|
|
156
|
+
expect(events).toHaveLength(1); // unchanged after remove()
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('happy-path: delivers validating → stitching → writing → done in order with correct refineStage', () => {
|
|
160
|
+
const stages: Array<{ stage: string | undefined; progress: number | undefined }> = [];
|
|
161
|
+
subscribeIncrementalState((s) => {
|
|
162
|
+
stages.push({ stage: s.refineStage, progress: s.refineProgress });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const sequence: Array<Pick<IncrementalState, 'refineStage' | 'refineProgress' | 'refineFrames'>> = [
|
|
166
|
+
{ refineStage: 'validating', refineProgress: 0.05, refineFrames: 3 },
|
|
167
|
+
{ refineStage: 'stitching', refineProgress: 0.10, refineFrames: 3 },
|
|
168
|
+
{ refineStage: 'writing', refineProgress: 0.90, refineFrames: 3 },
|
|
169
|
+
{ refineStage: 'done', refineProgress: 1.00, refineFrames: 3 },
|
|
170
|
+
];
|
|
171
|
+
for (const ev of sequence) {
|
|
172
|
+
lastEmitter!._emit('IncrementalStateUpdate', { ...makeBaseState(), ...ev } as IncrementalState);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(stages).toEqual([
|
|
176
|
+
{ stage: 'validating', progress: 0.05 },
|
|
177
|
+
{ stage: 'stitching', progress: 0.10 },
|
|
178
|
+
{ stage: 'writing', progress: 0.90 },
|
|
179
|
+
{ stage: 'done', progress: 1.00 },
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('refineProgress is non-decreasing across the happy-path sequence (monotonicity contract)', () => {
|
|
184
|
+
const progresses: number[] = [];
|
|
185
|
+
subscribeIncrementalState((s) => {
|
|
186
|
+
if (s.refineProgress !== undefined) progresses.push(s.refineProgress);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
for (const p of [0.05, 0.10, 0.90, 1.00]) {
|
|
190
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
191
|
+
...makeBaseState(),
|
|
192
|
+
refineStage: 'stitching', // stage is irrelevant for this assertion
|
|
193
|
+
refineProgress: p,
|
|
194
|
+
refineFrames: 3,
|
|
195
|
+
} as IncrementalState);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(progresses).toEqual([0.05, 0.10, 0.90, 1.00]);
|
|
199
|
+
for (let i = 1; i < progresses.length; i++) {
|
|
200
|
+
expect(progresses[i]).toBeGreaterThanOrEqual(progresses[i - 1]);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('failure-path: validating → error carries refineError; no further stages emitted', () => {
|
|
205
|
+
const events: IncrementalState[] = [];
|
|
206
|
+
subscribeIncrementalState((s) => events.push(s));
|
|
207
|
+
|
|
208
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
209
|
+
...makeBaseState(),
|
|
210
|
+
refineStage: 'validating',
|
|
211
|
+
refineProgress: 0.05,
|
|
212
|
+
refineFrames: 3,
|
|
213
|
+
} as IncrementalState);
|
|
214
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
215
|
+
...makeBaseState(),
|
|
216
|
+
refineStage: 'error',
|
|
217
|
+
refineProgress: 1.0,
|
|
218
|
+
refineFrames: 3,
|
|
219
|
+
refineError: 'INVALID_FRAMES: missing JPEG at index 1',
|
|
220
|
+
} as IncrementalState);
|
|
221
|
+
|
|
222
|
+
expect(events).toHaveLength(2);
|
|
223
|
+
expect(events[0].refineStage).toBe('validating');
|
|
224
|
+
expect(events[1].refineStage).toBe('error');
|
|
225
|
+
expect(events[1].refineError).toBe('INVALID_FRAMES: missing JPEG at index 1');
|
|
226
|
+
// refineError is absent on the validating event.
|
|
227
|
+
expect(events[0].refineError).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('refineFrames passes through unchanged (regression guard for key rename)', () => {
|
|
231
|
+
const seen: Array<number | undefined> = [];
|
|
232
|
+
subscribeIncrementalState((s) => seen.push(s.refineFrames));
|
|
233
|
+
|
|
234
|
+
for (const n of [3, 5, 8]) {
|
|
235
|
+
lastEmitter!._emit('IncrementalStateUpdate', {
|
|
236
|
+
...makeBaseState(),
|
|
237
|
+
refineStage: 'stitching',
|
|
238
|
+
refineProgress: 0.5,
|
|
239
|
+
refineFrames: n,
|
|
240
|
+
} as IncrementalState);
|
|
241
|
+
}
|
|
242
|
+
expect(seen).toEqual([3, 5, 8]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('live (non-refine) state events leave refine fields undefined', () => {
|
|
246
|
+
// Asserts that the contract is "refine fields are only populated
|
|
247
|
+
// during a refine call" — so the example app's `if
|
|
248
|
+
// (s.refineStage === undefined) return;` short-circuit is sound.
|
|
249
|
+
const events: IncrementalState[] = [];
|
|
250
|
+
subscribeIncrementalState((s) => events.push(s));
|
|
251
|
+
|
|
252
|
+
lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
|
|
253
|
+
expect(events).toHaveLength(1);
|
|
254
|
+
expect(events[0].refineStage).toBeUndefined();
|
|
255
|
+
expect(events[0].refineProgress).toBeUndefined();
|
|
256
|
+
expect(events[0].refineFrames).toBeUndefined();
|
|
257
|
+
expect(events[0].refineError).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('subscribes on the correct channel name "IncrementalStateUpdate" (cross-platform contract)', () => {
|
|
261
|
+
// If anyone renames the event constant on either side, the
|
|
262
|
+
// subscriber stops receiving events. Pin the literal here.
|
|
263
|
+
let receivedOnRight = false;
|
|
264
|
+
let receivedOnWrong = false;
|
|
265
|
+
subscribeIncrementalState(() => {
|
|
266
|
+
receivedOnRight = true;
|
|
267
|
+
});
|
|
268
|
+
// Fire on a deliberately-wrong channel — should NOT deliver.
|
|
269
|
+
lastEmitter!._emit('SomeOtherChannel', makeBaseState());
|
|
270
|
+
expect(receivedOnRight).toBe(false);
|
|
271
|
+
expect(receivedOnWrong).toBe(false);
|
|
272
|
+
// Fire on the right channel — should deliver.
|
|
273
|
+
lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
|
|
274
|
+
expect(receivedOnRight).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -261,6 +261,48 @@ export interface IncrementalState {
|
|
|
261
261
|
* keyframes on disk.
|
|
262
262
|
*/
|
|
263
263
|
refinedPanoramaPath?: string;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
|
|
267
|
+
* call. Fires from both the explicit `module.refinePanorama(...)`
|
|
268
|
+
* JS API path AND the hybrid-engine auto-refine path (which calls
|
|
269
|
+
* the same native refinePanorama internally).
|
|
270
|
+
*
|
|
271
|
+
* Lifecycle:
|
|
272
|
+
* - `"validating"` (fraction 0.05) — synchronous input checks
|
|
273
|
+
* - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
|
|
274
|
+
* - `"writing"` (fraction 0.90) — stitch done, JPEG written
|
|
275
|
+
* - `"done"` (fraction 1.00) — success
|
|
276
|
+
* - `"error"` (fraction 1.00) — failure; `refineError` is set
|
|
277
|
+
*
|
|
278
|
+
* Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
|
|
279
|
+
* progress, so the 0.10 → 0.90 jump is one opaque step. Use
|
|
280
|
+
* `refineStage` for a stage label; use `refineProgress` purely for
|
|
281
|
+
* spinner progress.
|
|
282
|
+
*
|
|
283
|
+
* Undefined when no refinement is in flight.
|
|
284
|
+
*/
|
|
285
|
+
refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
|
|
286
|
+
/**
|
|
287
|
+
* v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
|
|
288
|
+
* with `refineStage`. See `refineStage` for the per-stage value
|
|
289
|
+
* mapping. Undefined when no refinement is in flight.
|
|
290
|
+
*/
|
|
291
|
+
refineProgress?: number;
|
|
292
|
+
/**
|
|
293
|
+
* v0.10.0 (#15A) — number of input frames the in-flight refine is
|
|
294
|
+
* processing. Useful for the UI label
|
|
295
|
+
* (`Stitching 6 frames…`). Mirrors the `framesRequested` field
|
|
296
|
+
* returned in the explicit refinePanorama resolution. Undefined
|
|
297
|
+
* when no refinement is in flight.
|
|
298
|
+
*/
|
|
299
|
+
refineFrames?: number;
|
|
300
|
+
/**
|
|
301
|
+
* v0.10.0 (#15A) — present only when `refineStage === 'error'`.
|
|
302
|
+
* Human-readable error message; the same text the rejected promise
|
|
303
|
+
* carries. Use to render a one-line failure pill.
|
|
304
|
+
*/
|
|
305
|
+
refineError?: string;
|
|
264
306
|
}
|
|
265
307
|
|
|
266
308
|
|