react-native-image-stitcher 0.11.0 → 0.12.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 +116 -0
- package/README.md +28 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
- package/dist/camera/ARCameraView.d.ts +10 -0
- package/dist/camera/ARCameraView.js +1 -0
- package/dist/camera/Camera.d.ts +20 -0
- package/dist/camera/Camera.js +175 -6
- package/dist/camera/OrientationDriftModal.d.ts +83 -0
- package/dist/camera/OrientationDriftModal.js +159 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
- package/dist/camera/PanoramaBandOverlay.js +106 -45
- package/dist/camera/PanoramaSettingsModal.js +15 -1
- package/dist/camera/ViewportCropOverlay.d.ts +35 -31
- package/dist/camera/ViewportCropOverlay.js +39 -30
- package/dist/camera/useDeviceOrientation.d.ts +18 -9
- package/dist/camera/useDeviceOrientation.js +18 -9
- package/dist/camera/useOrientationDrift.d.ts +104 -0
- package/dist/camera/useOrientationDrift.js +120 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -1
- package/dist/stitching/incremental.d.ts +5 -3
- package/dist/stitching/useStitcherWorklet.js +25 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +58 -1
- package/package.json +2 -1
- package/src/camera/ARCameraView.tsx +18 -1
- package/src/camera/Camera.tsx +280 -13
- package/src/camera/OrientationDriftModal.tsx +224 -0
- package/src/camera/PanoramaBandOverlay.tsx +135 -49
- package/src/camera/PanoramaSettingsModal.tsx +14 -0
- package/src/camera/ViewportCropOverlay.tsx +52 -30
- package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
- package/src/camera/useDeviceOrientation.ts +18 -9
- package/src/camera/useOrientationDrift.ts +172 -0
- package/src/index.ts +13 -0
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +202 -0
- package/src/stitching/incremental.ts +5 -3
- package/src/stitching/useStitcherWorklet.ts +25 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* useOrientationDrift — detects mid-capture device rotation.
|
|
4
|
+
*
|
|
5
|
+
* Pairs with `useDeviceOrientation()` to surface the case where the
|
|
6
|
+
* user rotates the device *during* an active capture. The
|
|
7
|
+
* incremental stitching engine supports both portrait (Mode B,
|
|
8
|
+
* horizontal pan) and landscape (Mode A, vertical pan) capture
|
|
9
|
+
* modes as first-class — but mixing them mid-capture produces
|
|
10
|
+
* malformed output ("cross-mode capture is best-effort," per
|
|
11
|
+
* `incremental.ts:373-403`). Hosts that want to protect against
|
|
12
|
+
* this use this hook + `OrientationDriftModal` together: the
|
|
13
|
+
* `<Camera>` flagship component auto-abandons capture the instant
|
|
14
|
+
* `drifted === true` (PR-2 wiring); the modal surfaces an
|
|
15
|
+
* explanatory popup to the user.
|
|
16
|
+
*
|
|
17
|
+
* ## API contract
|
|
18
|
+
*
|
|
19
|
+
* Pass `active` true while a capture is in flight, false otherwise.
|
|
20
|
+
* Returns:
|
|
21
|
+
*
|
|
22
|
+
* - `captureOrientation` — the orientation snapshotted at the
|
|
23
|
+
* moment `active` transitioned false → true. `undefined` when
|
|
24
|
+
* `active` is false.
|
|
25
|
+
* - `currentOrientation` — live orientation from
|
|
26
|
+
* `useDeviceOrientation()`. Always defined (defaults to
|
|
27
|
+
* `'portrait'` until the accelerometer's first sample).
|
|
28
|
+
* - `drifted` — `true` IFF `active` is currently true AND
|
|
29
|
+
* `currentOrientation !== captureOrientation` at some point
|
|
30
|
+
* since the snapshot. **Latching** — once true, stays true
|
|
31
|
+
* until `active` flips back to false. This is intentional:
|
|
32
|
+
* after detection, callers should auto-abandon the capture
|
|
33
|
+
* (engine `stop()`); allowing the flag to clear before then
|
|
34
|
+
* would mask the drift if the user rotated back to the
|
|
35
|
+
* original orientation between the detection tick and the
|
|
36
|
+
* callers' abandonment effect.
|
|
37
|
+
*
|
|
38
|
+
* ## Semantics by transition
|
|
39
|
+
*
|
|
40
|
+
* - `active` false → true: snapshot `currentOrientation`;
|
|
41
|
+
* reset `drifted` to false.
|
|
42
|
+
* - `active` true (steady): if `currentOrientation !==
|
|
43
|
+
* captureOrientation` at any point, latch `drifted = true`.
|
|
44
|
+
* - `active` true → false: clear snapshot; reset `drifted`.
|
|
45
|
+
*
|
|
46
|
+
* ## Why a separate hook (rather than inlining in `<Camera>`)
|
|
47
|
+
*
|
|
48
|
+
* Hosts using the Layer-2 building blocks (`CameraView` directly,
|
|
49
|
+
* custom capture UX) can reuse this hook without mounting the
|
|
50
|
+
* full `<Camera>` flagship. Same composition pattern as
|
|
51
|
+
* `useIMUTranslationGate` and `useKeyframeStream`.
|
|
52
|
+
*
|
|
53
|
+
* ## Testing
|
|
54
|
+
*
|
|
55
|
+
* The pure state-transition function `_computeDriftStateForTests`
|
|
56
|
+
* is exported separately so jest can exercise all 5 transition
|
|
57
|
+
* cases without booting a React render. The hook itself is a
|
|
58
|
+
* thin wrapper around it (verified via on-device manual flow in
|
|
59
|
+
* the v0.12 verification checklist).
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { useEffect, useState } from 'react';
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
useDeviceOrientation,
|
|
66
|
+
type DeviceOrientation,
|
|
67
|
+
} from './useDeviceOrientation';
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
export interface UseOrientationDriftReturn {
|
|
71
|
+
/**
|
|
72
|
+
* `true` IFF a capture is active and the device has rotated since
|
|
73
|
+
* the snapshot taken at capture start. Latching: once true, stays
|
|
74
|
+
* true until `active` flips false.
|
|
75
|
+
*/
|
|
76
|
+
drifted: boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Snapshot of `currentOrientation` at the moment `active`
|
|
80
|
+
* transitioned false → true. `undefined` when `active` is false.
|
|
81
|
+
*/
|
|
82
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Live device orientation from `useDeviceOrientation()`. Always
|
|
86
|
+
* defined. Exposed so callers (e.g. the drift modal) can show
|
|
87
|
+
* "captured in PORTRAIT, now LANDSCAPE-LEFT" copy without
|
|
88
|
+
* mounting `useDeviceOrientation()` themselves.
|
|
89
|
+
*/
|
|
90
|
+
currentOrientation: DeviceOrientation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Internal state of the drift detector. Two scalar pieces: the
|
|
96
|
+
* snapshotted capture orientation (undefined when inactive) + the
|
|
97
|
+
* latched drift flag.
|
|
98
|
+
*/
|
|
99
|
+
interface DriftState {
|
|
100
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
101
|
+
drifted: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
const INITIAL_STATE: DriftState = {
|
|
106
|
+
captureOrientation: undefined,
|
|
107
|
+
drifted: false,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure state-transition function for the drift detector. Exported
|
|
113
|
+
* with a `_` prefix to signal "internal — not part of the public
|
|
114
|
+
* API." Jest uses this directly so tests don't need a React
|
|
115
|
+
* renderer (the lib's jest config is pure-data / no RN preset).
|
|
116
|
+
*
|
|
117
|
+
* Given the previous state + the current `active` flag + the
|
|
118
|
+
* current device orientation, returns the new state. Idempotent
|
|
119
|
+
* when nothing changed (returns the same object reference) so
|
|
120
|
+
* downstream `useState(setState)` calls become no-ops.
|
|
121
|
+
*/
|
|
122
|
+
export function _computeDriftStateForTests(
|
|
123
|
+
prev: DriftState,
|
|
124
|
+
active: boolean,
|
|
125
|
+
currentOrientation: DeviceOrientation,
|
|
126
|
+
): DriftState {
|
|
127
|
+
if (!active) {
|
|
128
|
+
// active is false (or just transitioned to false). Clear the
|
|
129
|
+
// snapshot + drift flag. Idempotent when already cleared.
|
|
130
|
+
if (prev.captureOrientation === undefined && !prev.drifted) {
|
|
131
|
+
return prev;
|
|
132
|
+
}
|
|
133
|
+
return INITIAL_STATE;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// active is true.
|
|
137
|
+
if (prev.captureOrientation === undefined) {
|
|
138
|
+
// false → true transition. Snapshot the current orientation.
|
|
139
|
+
// drifted starts false because, by definition, the current
|
|
140
|
+
// orientation matches itself.
|
|
141
|
+
return { captureOrientation: currentOrientation, drifted: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// active is steady true. Check for drift. Latching: once
|
|
145
|
+
// drifted is true, never set it back to false until active
|
|
146
|
+
// flips (handled above).
|
|
147
|
+
if (!prev.drifted && currentOrientation !== prev.captureOrientation) {
|
|
148
|
+
return { captureOrientation: prev.captureOrientation, drifted: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// No transition + no new drift. Return prev to avoid an
|
|
152
|
+
// unnecessary state update + re-render.
|
|
153
|
+
return prev;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
export function useOrientationDrift(
|
|
158
|
+
active: boolean,
|
|
159
|
+
): UseOrientationDriftReturn {
|
|
160
|
+
const currentOrientation = useDeviceOrientation();
|
|
161
|
+
const [state, setState] = useState<DriftState>(INITIAL_STATE);
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setState((prev) => _computeDriftStateForTests(prev, active, currentOrientation));
|
|
165
|
+
}, [active, currentOrientation]);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
drifted: state.drifted,
|
|
169
|
+
captureOrientation: state.captureOrientation,
|
|
170
|
+
currentOrientation,
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,19 @@ export { useCapture } from './camera/useCapture';
|
|
|
162
162
|
export type { TakePhotoCallOptions } from './camera/useCapture';
|
|
163
163
|
export { useVideoCapture } from './camera/useVideoCapture';
|
|
164
164
|
export { useDeviceOrientation } from './camera/useDeviceOrientation';
|
|
165
|
+
export type { DeviceOrientation } from './camera/useDeviceOrientation';
|
|
166
|
+
|
|
167
|
+
// v0.12.0 — orientation-aware Camera (R2-lite). `useOrientationDrift`
|
|
168
|
+
// snapshots the device orientation at capture start and latches a
|
|
169
|
+
// `drifted` flag if the user rotates mid-capture. Pairs with
|
|
170
|
+
// `OrientationDriftModal` for the auto-abandon UX flow. The
|
|
171
|
+
// flagship `<Camera>` component wires both internally (PR-2);
|
|
172
|
+
// Layer-2 hosts using `CameraView` directly can compose the pair
|
|
173
|
+
// manually (see the modal's docstring for the integration pattern).
|
|
174
|
+
export { useOrientationDrift } from './camera/useOrientationDrift';
|
|
175
|
+
export type { UseOrientationDriftReturn } from './camera/useOrientationDrift';
|
|
176
|
+
export { OrientationDriftModal } from './camera/OrientationDriftModal';
|
|
177
|
+
export type { OrientationDriftModalProps } from './camera/OrientationDriftModal';
|
|
165
178
|
|
|
166
179
|
// ── Incremental stitching engine ──────────────────────────────────────
|
|
167
180
|
// JS bindings around the native `IncrementalStitcher` module. Use
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for `useStitcherWorklet`.
|
|
4
|
+
*
|
|
5
|
+
* Coverage focus (v0.11.1):
|
|
6
|
+
*
|
|
7
|
+
* - **AR-source short-circuit.** The hook's docstring promises
|
|
8
|
+
* that AR-mode hosts can call `stitcher.call(frame)` from a
|
|
9
|
+
* single composed worklet body without per-mode branching; AR
|
|
10
|
+
* stitching runs natively via the AR-side dispatcher. Pre-
|
|
11
|
+
* v0.11.1 the code didn't enforce that — `stitcher.call` would
|
|
12
|
+
* invoke the vc Frame Processor plugin even on AR-source
|
|
13
|
+
* frames, which throws `getPropertyAsObject: property '__frame'
|
|
14
|
+
* is undefined` because AR frames are `StitcherFrameHostObject`
|
|
15
|
+
* instances and don't carry vc's JSI `Frame` proxy marker. The
|
|
16
|
+
* throw was caught silently by the per-worklet error handler in
|
|
17
|
+
* `RNSARWorkletRuntime.mm`, surfacing only as an `os_log` entry
|
|
18
|
+
* — invisible to JS, which is why composed hosts saw their
|
|
19
|
+
* post-`stitcher.call` lines (`fireFrameProcessorLog`,
|
|
20
|
+
* `runOnJS` callbacks) silently never execute in AR mode. Test
|
|
21
|
+
* 2 of `docs/v0.11.0-manual-verification-checklist.md`
|
|
22
|
+
* reproduced this on Ram's iPhone. This test pins the fix.
|
|
23
|
+
*
|
|
24
|
+
* - **vc-source happy path.** vc-source frames (and frames whose
|
|
25
|
+
* `source` is `undefined` — which is what vc's raw `Frame`
|
|
26
|
+
* looks like; the lib doesn't wrap vc frames in Phase 4a) MUST
|
|
27
|
+
* still invoke the plugin.
|
|
28
|
+
*
|
|
29
|
+
* ## Why mock React's hooks directly
|
|
30
|
+
*
|
|
31
|
+
* The hook owns state via `useState` (the JSI plugin handle) and
|
|
32
|
+
* side effects via `useEffect` (plugin acquisition retry loop + gyro
|
|
33
|
+
* subscription). The existing test pattern in this directory (see
|
|
34
|
+
* `useThrottledFrameProcessor.test.ts`) doesn't use a React renderer
|
|
35
|
+
* — instead it mocks the hooks the SUT calls so the SUT can be
|
|
36
|
+
* executed as a plain function. Same approach here: we mock
|
|
37
|
+
* `useState` to return a pre-resolved plugin, `useCallback` to
|
|
38
|
+
* return the function as-is, `useEffect` as a no-op (we don't need
|
|
39
|
+
* the plugin-acquisition retry or gyro for the call-routing test).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { StitcherFrame } from '../StitcherFrame';
|
|
43
|
+
|
|
44
|
+
// ─── Mock vision-camera ──────────────────────────────────────────
|
|
45
|
+
const pluginCallSpy = jest.fn();
|
|
46
|
+
const fakePlugin = { call: pluginCallSpy } as unknown as object;
|
|
47
|
+
|
|
48
|
+
jest.mock('react-native-vision-camera', () => ({
|
|
49
|
+
VisionCameraProxy: {
|
|
50
|
+
initFrameProcessorPlugin: jest.fn(() => fakePlugin),
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// ─── Mock react-native-worklets-core ─────────────────────────────
|
|
55
|
+
jest.mock('react-native-worklets-core', () => ({
|
|
56
|
+
useSharedValue: (initial: number) => ({ value: initial }),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// ─── Mock react-native-sensors ───────────────────────────────────
|
|
60
|
+
jest.mock('react-native-sensors', () => ({
|
|
61
|
+
gyroscope: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
|
|
62
|
+
setUpdateIntervalForType: jest.fn(),
|
|
63
|
+
SensorTypes: { gyroscope: 'gyroscope' },
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// ─── Mock React's hooks so the SUT runs as a plain function ──────
|
|
67
|
+
//
|
|
68
|
+
// `useState` returns the plugin pre-resolved. `useCallback` returns
|
|
69
|
+
// the function identity (deps array ignored — we're not testing
|
|
70
|
+
// re-render semantics). `useEffect` is a no-op (no plugin retry,
|
|
71
|
+
// no gyro subscription). This lets us call the hook synchronously
|
|
72
|
+
// and exercise the worklet body via the returned `call` function.
|
|
73
|
+
jest.mock('react', () => {
|
|
74
|
+
const actual = jest.requireActual('react');
|
|
75
|
+
return {
|
|
76
|
+
...actual,
|
|
77
|
+
useState: <T,>(initial: T): [T, (next: T) => void] => {
|
|
78
|
+
// For the [plugin, setPlugin] tuple: return the fake plugin
|
|
79
|
+
// immediately rather than starting at `null`. This skips the
|
|
80
|
+
// plugin-acquisition retry path and lets `call` actually
|
|
81
|
+
// invoke `plugin.call(...)`.
|
|
82
|
+
const resolved = (initial === null ? fakePlugin : initial) as T;
|
|
83
|
+
return [resolved, () => {}];
|
|
84
|
+
},
|
|
85
|
+
useEffect: () => {},
|
|
86
|
+
useCallback: <T,>(fn: T): T => fn,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// SUT — imported AFTER mocks so the hook sees them.
|
|
91
|
+
// eslint-disable-next-line import/first
|
|
92
|
+
import { useStitcherWorklet } from '../useStitcherWorklet';
|
|
93
|
+
|
|
94
|
+
describe('useStitcherWorklet', () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
pluginCallSpy.mockReset();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('AR-source short-circuit (v0.11.1 fix)', () => {
|
|
100
|
+
it('does NOT invoke the vc plugin for AR-source frames', () => {
|
|
101
|
+
const { call } = useStitcherWorklet();
|
|
102
|
+
const arFrame: StitcherFrame = {
|
|
103
|
+
width: 1920,
|
|
104
|
+
height: 1080,
|
|
105
|
+
pixelFormat: 'yuv',
|
|
106
|
+
orientation: 'landscape-right',
|
|
107
|
+
timestamp: 0,
|
|
108
|
+
toArrayBuffer: () => new ArrayBuffer(0),
|
|
109
|
+
source: 'ar',
|
|
110
|
+
pose: { rotation: [0, 0, 0, 1] },
|
|
111
|
+
};
|
|
112
|
+
call(arFrame);
|
|
113
|
+
expect(pluginCallSpy).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does NOT invoke the vc plugin for AR-source frames even when called repeatedly', () => {
|
|
117
|
+
const { call } = useStitcherWorklet();
|
|
118
|
+
const arFrame: StitcherFrame = {
|
|
119
|
+
width: 1920,
|
|
120
|
+
height: 1080,
|
|
121
|
+
pixelFormat: 'yuv',
|
|
122
|
+
orientation: 'landscape-right',
|
|
123
|
+
timestamp: 0,
|
|
124
|
+
toArrayBuffer: () => new ArrayBuffer(0),
|
|
125
|
+
source: 'ar',
|
|
126
|
+
pose: { rotation: [0, 0, 0, 1] },
|
|
127
|
+
};
|
|
128
|
+
for (let i = 0; i < 30; i++) call(arFrame);
|
|
129
|
+
expect(pluginCallSpy).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('vc-source happy path', () => {
|
|
134
|
+
it('invokes the vc plugin for vc-source frames', () => {
|
|
135
|
+
const { call } = useStitcherWorklet();
|
|
136
|
+
const vcFrame: StitcherFrame = {
|
|
137
|
+
width: 1920,
|
|
138
|
+
height: 1080,
|
|
139
|
+
pixelFormat: 'yuv',
|
|
140
|
+
orientation: 'landscape-right',
|
|
141
|
+
timestamp: 0,
|
|
142
|
+
toArrayBuffer: () => new ArrayBuffer(0),
|
|
143
|
+
source: 'vc',
|
|
144
|
+
pose: { rotation: [0, 0, 0, 1] },
|
|
145
|
+
};
|
|
146
|
+
call(vcFrame);
|
|
147
|
+
expect(pluginCallSpy).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('invokes the vc plugin for frames with undefined source (raw vc Frame)', () => {
|
|
151
|
+
// vc's raw `Frame` doesn't carry the `source` field — the lib's
|
|
152
|
+
// Phase 4a deferral means we don't wrap vc frames into
|
|
153
|
+
// `StitcherFrame`. The AR-source check must treat undefined
|
|
154
|
+
// as "not AR" to preserve the non-AR worklet path.
|
|
155
|
+
const { call } = useStitcherWorklet();
|
|
156
|
+
const rawVcFrame = {
|
|
157
|
+
width: 1920,
|
|
158
|
+
height: 1080,
|
|
159
|
+
pixelFormat: 'yuv',
|
|
160
|
+
orientation: 'landscape-right',
|
|
161
|
+
timestamp: 0,
|
|
162
|
+
toArrayBuffer: () => new ArrayBuffer(0),
|
|
163
|
+
// `source` intentionally absent
|
|
164
|
+
} as unknown as StitcherFrame;
|
|
165
|
+
call(rawVcFrame);
|
|
166
|
+
expect(pluginCallSpy).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('plugin.call payload shape', () => {
|
|
171
|
+
it('passes the frame + a numeric-intrinsics params object', () => {
|
|
172
|
+
const { call } = useStitcherWorklet();
|
|
173
|
+
const vcFrame: StitcherFrame = {
|
|
174
|
+
width: 1920,
|
|
175
|
+
height: 1080,
|
|
176
|
+
pixelFormat: 'yuv',
|
|
177
|
+
orientation: 'landscape-right',
|
|
178
|
+
timestamp: 0,
|
|
179
|
+
toArrayBuffer: () => new ArrayBuffer(0),
|
|
180
|
+
source: 'vc',
|
|
181
|
+
pose: { rotation: [0, 0, 0, 1] },
|
|
182
|
+
};
|
|
183
|
+
call(vcFrame);
|
|
184
|
+
expect(pluginCallSpy).toHaveBeenCalledWith(
|
|
185
|
+
vcFrame,
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
tx: 0, ty: 0, tz: 0,
|
|
188
|
+
qx: expect.any(Number),
|
|
189
|
+
qy: expect.any(Number),
|
|
190
|
+
qz: expect.any(Number),
|
|
191
|
+
qw: expect.any(Number),
|
|
192
|
+
fx: expect.any(Number),
|
|
193
|
+
fy: expect.any(Number),
|
|
194
|
+
cx: 960,
|
|
195
|
+
cy: 540,
|
|
196
|
+
imageWidth: 1920,
|
|
197
|
+
imageHeight: 1080,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -139,9 +139,11 @@ export interface IncrementalState {
|
|
|
139
139
|
* at the FIRST-FRAME determination thereafter.
|
|
140
140
|
*
|
|
141
141
|
* **This is the single source of truth for orientation across
|
|
142
|
-
* the SDK + host.**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
142
|
+
* the SDK + host.** Pose-derived detection is preferred over
|
|
143
|
+
* JS-side hooks because it works identically regardless of host
|
|
144
|
+
* configuration — `useWindowDimensions` reports JS-portrait when
|
|
145
|
+
* the host is portrait-locked (even with the device in landscape),
|
|
146
|
+
* while pose data reflects what the camera actually saw. UI
|
|
145
147
|
* components that need to know orientation (band overlay, dim
|
|
146
148
|
* bars, pan guide) MUST consume `state.isLandscape` rather
|
|
147
149
|
* than re-detecting.
|
|
@@ -336,6 +336,31 @@ export function useStitcherWorklet(
|
|
|
336
336
|
'worklet';
|
|
337
337
|
if (plugin == null) return;
|
|
338
338
|
|
|
339
|
+
// v0.11.1 — AR-source frames are stitched natively by the AR-
|
|
340
|
+
// side dispatcher (`RNSARSession.swift:510-511` → the first-
|
|
341
|
+
// party callback installed in `RNSARWorkletRuntime`). Calling
|
|
342
|
+
// the vc Frame Processor plugin here would throw
|
|
343
|
+
// `getPropertyAsObject: property '__frame' is undefined`
|
|
344
|
+
// because AR frames are `StitcherFrameHostObject` instances
|
|
345
|
+
// and don't carry the vc `Frame` proxy's JSI marker. The
|
|
346
|
+
// throw is caught silently by the per-worklet error handler
|
|
347
|
+
// (`RNSARWorkletRuntime.mm:284-301`) and bubbles up only to
|
|
348
|
+
// `os_log` — invisible to JS, which is why pre-v0.11.1
|
|
349
|
+
// composed hosts saw their post-`stitcher.call` lines
|
|
350
|
+
// (`fireFrameProcessorLog`, `runOnJS` callbacks) silently
|
|
351
|
+
// never execute in AR mode. Silent no-op here matches the
|
|
352
|
+
// module-header promise that AR mode is "unaffected" by this
|
|
353
|
+
// hook (the AR-side stitching path runs natively, independent
|
|
354
|
+
// of the composed worklet body).
|
|
355
|
+
//
|
|
356
|
+
// The `(frame as StitcherFrame).source` cast is safe: vc
|
|
357
|
+
// `Frame` doesn't carry a `source` property so the check
|
|
358
|
+
// returns `undefined !== 'ar'` → `true`, and the worklet
|
|
359
|
+
// proceeds normally. Only frames that explicitly tag
|
|
360
|
+
// themselves as AR-source (which our native AR dispatcher
|
|
361
|
+
// does — see `StitcherFrameHostObject.mm`) get short-circuited.
|
|
362
|
+
if ((frame as StitcherFrame).source === 'ar') return;
|
|
363
|
+
|
|
339
364
|
// Throttle (verbatim from useFrameProcessorDriver).
|
|
340
365
|
sharedFrameCounter.value += 1;
|
|
341
366
|
const N = sharedEvalEveryN.value;
|