react-native-image-stitcher 0.7.1 → 0.9.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/android/build.gradle +35 -1
  3. package/android/src/main/cpp/CMakeLists.txt +64 -2
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +227 -0
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +30 -11
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +21 -3
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
  8. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
  11. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
  12. package/cpp/stitcher_frame_data.hpp +141 -0
  13. package/cpp/stitcher_frame_jsi.cpp +214 -0
  14. package/cpp/stitcher_frame_jsi.hpp +108 -0
  15. package/cpp/stitcher_proxy_jsi.cpp +109 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +46 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  18. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  19. package/cpp/stitcher_worklet_registry.cpp +81 -0
  20. package/cpp/stitcher_worklet_registry.hpp +136 -0
  21. package/dist/camera/Camera.d.ts +62 -12
  22. package/dist/camera/Camera.js +30 -15
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +30 -1
  25. package/dist/stitching/StitcherFrame.d.ts +170 -0
  26. package/dist/stitching/StitcherFrame.js +4 -0
  27. package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
  28. package/dist/stitching/StitcherWorkletRegistry.js +78 -0
  29. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  30. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  31. package/dist/stitching/useFrameProcessor.d.ts +119 -0
  32. package/dist/stitching/useFrameProcessor.js +196 -0
  33. package/dist/stitching/useFrameStream.d.ts +34 -0
  34. package/dist/stitching/useFrameStream.js +219 -0
  35. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  36. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  37. package/dist/types.d.ts +87 -0
  38. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
  39. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  41. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  42. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
  43. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
  44. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  45. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
  46. package/package.json +1 -1
  47. package/src/camera/Camera.tsx +93 -28
  48. package/src/index.ts +35 -0
  49. package/src/stitching/StitcherFrame.ts +197 -0
  50. package/src/stitching/StitcherWorkletRegistry.ts +156 -0
  51. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
  52. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
  53. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  54. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  55. package/src/stitching/useFrameProcessor.ts +226 -0
  56. package/src/stitching/useFrameStream.ts +255 -0
  57. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  58. package/src/types.ts +95 -0
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // v0.9.0 Layer 3 — JS-thread sampled-frame stream over Layer 1 +
5
+ // Layer 2.
6
+ //
7
+ // ## What this is
8
+ //
9
+ // A hook that:
10
+ // 1. Throttles a worklet via `useThrottledFrameProcessor` (Layer 2)
11
+ // to fire at `sampleHz` Hz.
12
+ // 2. Inside the worklet, calls the `save_frame_as_jpeg` vc Frame
13
+ // Processor plugin (Layer 1) to JPEG-encode the frame to a
14
+ // bounded-rotation slot on disk.
15
+ // 3. Bridges the resulting `SampledFrame` (file path + pose +
16
+ // dims) to a JS-thread callback via `runOnJS`.
17
+ //
18
+ // The host gets a per-sample callback on the JS thread with a file
19
+ // path they can pass to `<Image>`, an OCR RN module, a cloud-upload
20
+ // library, etc. Zero worklet boilerplate.
21
+ //
22
+ // ## When to use this (vs alternatives)
23
+ //
24
+ // - **`useFrameStream`** (this hook) — JS-thread consumers. File-
25
+ // path OCR libraries, cloud upload, thumbnail UI, sampled
26
+ // server-side analysis.
27
+ // - **`useThrottledFrameProcessor`** (Layer 2) — worklet-native
28
+ // consumers. Native OCR (Vision.framework / ML Kit) wrapped as
29
+ // vc plugins, TFLite ML inference, LiDAR depth processing.
30
+ // Lower latency; no JPEG roundtrip.
31
+ // - **`useFrameProcessor`** — every camera frame; full control.
32
+ //
33
+ // ## Slot reuse / disk usage
34
+ //
35
+ // JPEG files are written to `<outputDir>/stream-<N>.jpg` where N
36
+ // cycles 0..3 based on `frame.timestamp / 1000`. At most 4 stale
37
+ // JPEGs ever exist on disk; the same file is rewritten on each
38
+ // rotation, so disk usage is bounded.
39
+ //
40
+ // Hosts that need long-term retention (e.g., archive each sample
41
+ // for later upload) MUST copy the file synchronously inside the
42
+ // handler — the slot may be overwritten by the next sample.
43
+ //
44
+ // ## Backpressure
45
+ //
46
+ // If the JS handler returns slower than `1/sampleHz`, subsequent
47
+ // ticks DO still fire (the throttle is time-based, not handler-
48
+ // completion-based). This means multiple handler invocations can
49
+ // be in flight simultaneously. For most use cases that's fine
50
+ // (the handlers are pure or commute). Hosts that need serialised
51
+ // handling should track in-flight state themselves and early-return.
52
+ //
53
+ // ## AR vs non-AR
54
+ //
55
+ // Works in both modes because it composes over
56
+ // `useThrottledFrameProcessor` → `useFrameProcessor`. In AR mode
57
+ // the worklet auto-registers via `__stitcherProxy` (v0.8.0 Phase
58
+ // 4b.i/iii); in non-AR mode the returned processor object is
59
+ // passed to `<Camera frameProcessor={...}>`. The hook returns
60
+ // the processor object so hosts can wire it up either way.
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.useFrameStream = useFrameStream;
63
+ const react_1 = require("react");
64
+ const react_native_1 = require("react-native");
65
+ const react_native_vision_camera_1 = require("react-native-vision-camera");
66
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
67
+ const useThrottledFrameProcessor_1 = require("./useThrottledFrameProcessor");
68
+ /**
69
+ * `useFrameStream` — Layer 3. See module docstring for the full
70
+ * design + use-case mapping. Quick start:
71
+ *
72
+ * ```tsx
73
+ * import { Camera, useFrameStream } from 'react-native-image-stitcher';
74
+ *
75
+ * function MyScreen() {
76
+ * const fp = useFrameStream(
77
+ * { sampleHz: 2, quality: 75 },
78
+ * (sample) => {
79
+ * setThumbnail(sample.jpegPath);
80
+ * },
81
+ * );
82
+ * return <Camera frameProcessor={fp} ... />;
83
+ * }
84
+ * ```
85
+ *
86
+ * @param options `{ sampleHz, quality?, outputDir? }`. `sampleHz`
87
+ * clamped to `[0.5, 10]`.
88
+ * @param handler JS-thread callback fired per sample. Receives a
89
+ * `SampledFrame`. May return a Promise; rejections
90
+ * are caught + logged (not re-thrown) so one
91
+ * misbehaving handler doesn't break the stream.
92
+ *
93
+ * @returns A `useFrameProcessor`-shaped processor object — pass to
94
+ * `<Camera frameProcessor={...}>` for non-AR mode wiring.
95
+ * (AR mode auto-registration via `__stitcherProxy` is
96
+ * handled inside `useFrameProcessor`.)
97
+ */
98
+ function useFrameStream(options, handler) {
99
+ const sampleHz = Math.max(0.5, Math.min(10, options.sampleHz));
100
+ const quality = options.quality ?? 75;
101
+ // Default output dir: a per-app cache subdirectory. Hosts that
102
+ // want a known path supply their own via `options.outputDir`.
103
+ // `Platform.OS`-specific cache paths are read once at hook mount.
104
+ const outputDir = (0, react_1.useMemo)(() => {
105
+ if (options.outputDir != null)
106
+ return options.outputDir;
107
+ // Both platforms expose a cache directory at a predictable path
108
+ // via React Native APIs; we use a small inline computation to
109
+ // avoid pulling `react-native-fs` as a hard dep. The lib's
110
+ // existing JPEG encode targets the app's data dir via similar
111
+ // logic in `RNSARCameraView.kt` / `IncrementalStitcher.swift`.
112
+ //
113
+ // We just generate a relative-ish path under /tmp/ for cross-
114
+ // platform simplicity; the native plugin writes wherever it's
115
+ // told to (absolute path), so as long as the directory exists
116
+ // the encode succeeds. Hosts that care about file lifecycle
117
+ // should supply `outputDir` explicitly.
118
+ return react_native_1.Platform.OS === 'ios'
119
+ ? '/tmp/rnis-frame-stream'
120
+ : '/data/local/tmp/rnis-frame-stream';
121
+ }, [options.outputDir]);
122
+ // Ensure outputDir exists on the native side. We could use
123
+ // react-native-fs but to keep the dep surface minimal, we just
124
+ // attempt to create via a tiny native call — or, simpler, accept
125
+ // that the plugin's write call will fail if the dir doesn't
126
+ // exist + log a clear error. For v0.9.0 baseline we defer
127
+ // mkdir to the host (document it in the option's JSDoc) OR fall
128
+ // back to the platform's tmpdir which already exists.
129
+ //
130
+ // The tmpdir defaults above always exist on iOS + Android, so
131
+ // the common case "host doesn't supply outputDir" Just Works.
132
+ // Stable JS-side handler reference for `runOnJS`. The hook re-
133
+ // captures `handler` on every render but the ref keeps the
134
+ // worklet closure pointing at the latest callback (avoid stale
135
+ // captures).
136
+ const handlerRef = (0, react_1.useRef)(handler);
137
+ handlerRef.current = handler;
138
+ const onSampleJS = (0, react_1.useCallback)((sample) => {
139
+ const result = handlerRef.current(sample);
140
+ if (result != null &&
141
+ typeof result.catch === 'function') {
142
+ result.catch((err) => {
143
+ // eslint-disable-next-line no-console
144
+ console.error('[useFrameStream] handler threw:', err);
145
+ });
146
+ }
147
+ }, []);
148
+ const onSampleOnJS = (0, react_1.useMemo)(() => react_native_worklets_core_1.Worklets.createRunOnJS(onSampleJS), [onSampleJS]);
149
+ // ── Plugin acquisition (Layer 1) ─────────────────────────────────
150
+ //
151
+ // `initFrameProcessorPlugin` can return `undefined` if the native
152
+ // registry hasn't initialised yet (rare race on app start). We
153
+ // retry every 16ms (one display frame) until success — matches
154
+ // the pattern in `useFrameProcessorDriver`.
155
+ const pluginRef = (0, react_1.useRef)(null);
156
+ (0, react_1.useEffect)(() => {
157
+ let cancelled = false;
158
+ let timerId = null;
159
+ const tryAcquire = () => {
160
+ if (cancelled)
161
+ return;
162
+ const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('save_frame_as_jpeg', {});
163
+ if (p != null) {
164
+ pluginRef.current = p;
165
+ return;
166
+ }
167
+ timerId = setTimeout(tryAcquire, 16);
168
+ };
169
+ tryAcquire();
170
+ return () => {
171
+ cancelled = true;
172
+ if (timerId != null)
173
+ clearTimeout(timerId);
174
+ };
175
+ }, []);
176
+ // The worklet body — fires at sampleHz, calls the JPEG plugin,
177
+ // bridges the result to JS. Note we read `pluginRef.current`
178
+ // inside the worklet via the captured `plugin` value below;
179
+ // worklets-core handles the JS↔worklet reference.
180
+ const plugin = pluginRef.current;
181
+ return (0, useThrottledFrameProcessor_1.useThrottledFrameProcessor)((frame) => {
182
+ 'worklet';
183
+ if (plugin == null)
184
+ return;
185
+ // Slot rotation: compute slot from frame timestamp. At
186
+ // sampleHz=2 (500ms interval), the slot index changes every
187
+ // ~1s, giving each slot ~2 samples before being overwritten.
188
+ // That's overkill for the "stream-of-samples" use case but
189
+ // matches the docstring's "at most 4 stale JPEGs" guarantee.
190
+ const slot = Math.floor(frame.timestamp / 1000) % 4;
191
+ const path = `${outputDir}/stream-${slot}.jpg`;
192
+ // vc's `FrameProcessorPlugin.call` expects vc's `Frame` type.
193
+ // `StitcherFrame` is structurally a superset (it adds `source`,
194
+ // `pose`, AR-only fields). Cast through `unknown` — same
195
+ // pattern v0.8.0's `useFrameProcessor` uses when handing a
196
+ // StitcherFrame-typed worklet to vc.
197
+ const result = plugin.call(frame, {
198
+ path,
199
+ quality,
200
+ });
201
+ if (result == null ||
202
+ result.ok !== true) {
203
+ // Native side reported an error (path not writable, format
204
+ // wrong, etc.). Silently skip this sample — the next tick
205
+ // will retry. The plugin already logs the specific reason
206
+ // on the native side.
207
+ return;
208
+ }
209
+ const r = result;
210
+ onSampleOnJS({
211
+ jpegPath: r.path,
212
+ pose: frame.pose,
213
+ timestamp: frame.timestamp,
214
+ width: r.width,
215
+ height: r.height,
216
+ });
217
+ }, { sampleHz }, [plugin, outputDir, quality, onSampleOnJS]);
218
+ }
219
+ //# sourceMappingURL=useFrameStream.js.map
@@ -0,0 +1,33 @@
1
+ import type { DependencyList } from 'react';
2
+ import { useFrameProcessor } from './useFrameProcessor';
3
+ import type { StitcherFrameProcessor } from './StitcherFrame';
4
+ import type { ThrottledFrameProcessorOptions } from '../types';
5
+ /**
6
+ * Throttled variant of `useFrameProcessor`. See the module
7
+ * docstring for the full use-case mapping; quick version:
8
+ *
9
+ * ```tsx
10
+ * const fp = useThrottledFrameProcessor(
11
+ * (frame) => {
12
+ * 'worklet';
13
+ * // worklet-native OCR / ML / depth processing here
14
+ * },
15
+ * { sampleHz: 2 },
16
+ * [],
17
+ * );
18
+ * return <Camera frameProcessor={fp} ... />;
19
+ * ```
20
+ *
21
+ * @param worklet Host's frame-processor worklet. Must be
22
+ * `'worklet'`-prefixed. Runs at most `sampleHz`
23
+ * times per second.
24
+ * @param options `{ sampleHz }` — clamped to `[0.5, 30]`.
25
+ * @param deps Standard React deps array. Treated the same as
26
+ * `useFrameProcessor`'s deps — when they change the
27
+ * inner worklet is re-bound.
28
+ *
29
+ * @returns A `useFrameProcessor`-shaped processor object, pass it
30
+ * to `<Camera frameProcessor={...}>`.
31
+ */
32
+ export declare function useThrottledFrameProcessor(worklet: StitcherFrameProcessor, options: ThrottledFrameProcessorOptions, deps: DependencyList): ReturnType<typeof useFrameProcessor>;
33
+ //# sourceMappingURL=useThrottledFrameProcessor.d.ts.map
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // v0.9.0 Layer 2 — throttle gate over v0.8.0's `useFrameProcessor`.
5
+ //
6
+ // ## What this is
7
+ //
8
+ // A thin wrapper around `useFrameProcessor` that enforces a maximum
9
+ // invocation rate (`sampleHz`) at the worklet layer. The host's
10
+ // worklet fires up to `sampleHz` times per second; ticks too close
11
+ // together are dropped via a `useSharedValue<number>` monotonic-time
12
+ // gate inside the worklet body.
13
+ //
14
+ // ## When to use this (vs alternatives)
15
+ //
16
+ // - **`useFrameProcessor` directly** — every camera frame (~30-60 Hz).
17
+ // Use for true-realtime processing that wants to see every frame.
18
+ // - **`useThrottledFrameProcessor`** (this hook) — sub-frame-rate
19
+ // worklet-native processing. The worklet runtime has direct
20
+ // access to `frame.toArrayBuffer()`, `frame.arDepth`,
21
+ // `frame.arAnchors`, and can call other vc Frame Processor plugins
22
+ // (native OCR libraries, TFLite ML inference, etc.). Results
23
+ // bridged to JS via `runOnJS`.
24
+ // - **`useFrameStream`** (Layer 3, also in this directory) —
25
+ // sub-frame-rate JS-thread consumer. The lib JPEG-encodes each
26
+ // sample on the producer thread and delivers a `SampledFrame`
27
+ // (file path + pose + dims) to a JS-thread callback. Use for
28
+ // file-path OCR libraries (RN modules wrapping ML Kit etc.),
29
+ // cloud upload, thumbnail UI.
30
+ //
31
+ // ## Use-case mapping (canonical)
32
+ //
33
+ // | Use case | Layer | Why |
34
+ // |---------------------------------------|-------|----------------------------------|
35
+ // | OCR via Vision.framework / ML Kit | **2** | native libs, bbox in frame coords|
36
+ // | TFLite ML detection (via vc plugin) | **2** | same shape as OCR |
37
+ // | LiDAR depth → 3D reconstruction | **2** | depth too large to bridge |
38
+ // | Pose-only telemetry | **2** | tiny payload, no encoding needed |
39
+ // | File-path OCR (RN module) | 3 | host wants a JPEG, not pixels |
40
+ // | Cloud upload (sampled JPEG feed) | 3 | JPEG IS the payload |
41
+ // | Live thumbnail preview UI | 3 | `<Image source={{uri: ...}}>` |
42
+ //
43
+ // See `docs/host-app-integration.md` § "Tier 2 + 3" for recipes.
44
+ //
45
+ // ## Threading
46
+ //
47
+ // The wrapped worklet fires on whatever runtime `useFrameProcessor`
48
+ // dispatches on:
49
+ // - **Non-AR mode**: vision-camera's Frame Processor runtime
50
+ // (producer thread).
51
+ // - **AR mode**: the lib's `RNSARWorkletRuntime` (iOS) /
52
+ // worklets-core default context (Android) — fired by the AR
53
+ // session's per-frame dispatch. See v0.8.0 Phase 4b.i / 4b.iii.
54
+ //
55
+ // Either way, the worklet MUST NOT block — the next frame's
56
+ // processing is gated on this one returning. Long work belongs
57
+ // behind `runOnJS` / a separate worklet runtime.
58
+ //
59
+ // ## Behaviour at the throttle boundary
60
+ //
61
+ // The hook tracks a monotonic-time shared value of "last sample time".
62
+ // Each tick checks if `frame.timestamp - lastSampleMs.value >=
63
+ // (1000 / sampleHz)`. If yes, the worklet body runs and the value
64
+ // updates; if no, the worklet returns silently.
65
+ //
66
+ // Edge cases:
67
+ // - First-ever tick: `lastSampleMs.value` starts at 0; first frame's
68
+ // timestamp will be >> 0 → first tick always fires. Subsequent
69
+ // ticks throttle as expected.
70
+ // - vc v4 timestamp semantics: per the project's worklet-throttle
71
+ // gotcha note, `frame.timestamp` is NOT reliably nanoseconds in
72
+ // vc v4. The hook treats `frame.timestamp` as ALREADY in
73
+ // milliseconds (which is what vc v4 actually delivers; the
74
+ // v0.8.0 StitcherFrame contract documents this). If a future
75
+ // vc version changes the unit, the throttle math here needs
76
+ // re-checking.
77
+ Object.defineProperty(exports, "__esModule", { value: true });
78
+ exports.useThrottledFrameProcessor = useThrottledFrameProcessor;
79
+ const react_native_worklets_core_1 = require("react-native-worklets-core");
80
+ const useFrameProcessor_1 = require("./useFrameProcessor");
81
+ /**
82
+ * Throttled variant of `useFrameProcessor`. See the module
83
+ * docstring for the full use-case mapping; quick version:
84
+ *
85
+ * ```tsx
86
+ * const fp = useThrottledFrameProcessor(
87
+ * (frame) => {
88
+ * 'worklet';
89
+ * // worklet-native OCR / ML / depth processing here
90
+ * },
91
+ * { sampleHz: 2 },
92
+ * [],
93
+ * );
94
+ * return <Camera frameProcessor={fp} ... />;
95
+ * ```
96
+ *
97
+ * @param worklet Host's frame-processor worklet. Must be
98
+ * `'worklet'`-prefixed. Runs at most `sampleHz`
99
+ * times per second.
100
+ * @param options `{ sampleHz }` — clamped to `[0.5, 30]`.
101
+ * @param deps Standard React deps array. Treated the same as
102
+ * `useFrameProcessor`'s deps — when they change the
103
+ * inner worklet is re-bound.
104
+ *
105
+ * @returns A `useFrameProcessor`-shaped processor object, pass it
106
+ * to `<Camera frameProcessor={...}>`.
107
+ */
108
+ function useThrottledFrameProcessor(worklet, options, deps) {
109
+ // Clamp + derive interval. Done outside the worklet so the
110
+ // useSharedValue / useFrameProcessor hooks see stable values.
111
+ const sampleHz = Math.max(0.5, Math.min(30, options.sampleHz));
112
+ const minIntervalMs = 1000 / sampleHz;
113
+ // Monotonic-time gate. Initialised to 0 → first tick always
114
+ // fires (frame.timestamp >> 0).
115
+ const lastSampleMs = (0, react_native_worklets_core_1.useSharedValue)(0);
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ return (0, useFrameProcessor_1.useFrameProcessor)((frame) => {
118
+ 'worklet';
119
+ const now = frame.timestamp;
120
+ if (now - lastSampleMs.value < minIntervalMs) {
121
+ return;
122
+ }
123
+ lastSampleMs.value = now;
124
+ worklet(frame);
125
+ },
126
+ // The throttle interval is captured in the worklet closure; if
127
+ // it changes we need to re-bind the worklet so the new
128
+ // `minIntervalMs` takes effect. Same for the host's worklet
129
+ // identity (so deps changes on the host side re-bind too).
130
+ [minIntervalMs, worklet, ...deps]);
131
+ }
132
+ //# sourceMappingURL=useThrottledFrameProcessor.js.map
package/dist/types.d.ts CHANGED
@@ -35,6 +35,93 @@ export interface DeviceMetadata {
35
35
  cameraId: string;
36
36
  flashEnabled: boolean;
37
37
  }
38
+ /**
39
+ * v0.9.0 Layer 3 — one sampled frame delivered by `useFrameStream`
40
+ * to the JS-thread handler.
41
+ *
42
+ * The JPEG file at `jpegPath` is the stream's own copy. Hosts that
43
+ * need long-term retention MUST copy the file synchronously inside
44
+ * the handler — the same path may be overwritten by a subsequent
45
+ * sample (slot reuse — see the hook's docstring for the rotation
46
+ * policy).
47
+ */
48
+ export interface SampledFrame {
49
+ /** Absolute filesystem path to the JPEG. No `file://` prefix. */
50
+ jpegPath: string;
51
+ /**
52
+ * Pose at sample time. `translation` is `undefined` in non-AR
53
+ * mode (gyro provides rotation only; no spatial anchor).
54
+ */
55
+ pose: {
56
+ rotation: [number, number, number, number];
57
+ translation?: [number, number, number];
58
+ };
59
+ /** Frame timestamp (ms; per the v0.8.0 StitcherFrame contract). */
60
+ timestamp: number;
61
+ /** JPEG width / height in pixels. */
62
+ width: number;
63
+ height: number;
64
+ }
65
+ /**
66
+ * v0.9.0 Layer 3 — options for `useFrameStream`.
67
+ *
68
+ * For worklet-native processing without JPEG roundtrip (OCR via
69
+ * Vision/ML Kit, TFLite ML, LiDAR depth), use
70
+ * `useThrottledFrameProcessor` (Layer 2) instead.
71
+ */
72
+ export interface FrameStreamOptions {
73
+ /**
74
+ * Target sampling rate in Hertz. Clamped to `[0.5, 10]`. The
75
+ * Layer 2 throttle gate enforces the rate inside the worklet;
76
+ * ticks too close together are dropped silently.
77
+ *
78
+ * Clamp upper bound (10 Hz) is intentionally lower than Layer 2's
79
+ * (30 Hz) — beyond 10 Hz the per-frame JPEG encode + JS-bridge
80
+ * cost dominates the wall-clock budget. Hosts that need higher
81
+ * rates should be on Layer 2 with their own JPEG encoder call
82
+ * (or no JPEG at all).
83
+ */
84
+ sampleHz: number;
85
+ /**
86
+ * JPEG quality (0-100). Default 75. Clamped silently to
87
+ * `[1, 100]` by the underlying `save_frame_as_jpeg` native plugin.
88
+ */
89
+ quality?: number;
90
+ /**
91
+ * Directory to write JPEG files into. Defaults to a per-app
92
+ * `<cache>/rnis-frame-stream/` subdirectory. The directory is
93
+ * `mkdir -p`'d on first use; hosts that supply an existing
94
+ * absolute path are responsible for its lifecycle.
95
+ */
96
+ outputDir?: string;
97
+ }
98
+ /**
99
+ * v0.9.0 Layer 2 — options for `useThrottledFrameProcessor`.
100
+ *
101
+ * Wraps v0.8.0's `useFrameProcessor` with a monotonic-time throttle
102
+ * gate so the supplied worklet fires at most `sampleHz` times per
103
+ * second. Use for sub-frame-rate worklet-native processing — native
104
+ * OCR (Vision.framework / ML Kit), TFLite ML detection, LiDAR depth
105
+ * processing — where the bbox / depth payloads are small enough to
106
+ * bridge to JS via `runOnJS`.
107
+ *
108
+ * For JS-thread JPEG consumers (file-path OCR libraries, cloud
109
+ * upload, thumbnail UI), use `useFrameStream` (Layer 3) instead.
110
+ */
111
+ export interface ThrottledFrameProcessorOptions {
112
+ /**
113
+ * Target sampling rate in Hertz. Clamped to `[0.5, 30]`. Inside
114
+ * the worklet a monotonic-time gate enforces the rate; ticks too
115
+ * close together are silently dropped.
116
+ *
117
+ * The clamp upper bound (30 Hz) sits at typical AR rates on
118
+ * mid-range Android devices — beyond that, the host should just
119
+ * use `useFrameProcessor` directly (no throttle). The clamp
120
+ * lower bound (0.5 Hz) prevents accidentally-zero-divide values
121
+ * + matches `useFrameStream`'s convention.
122
+ */
123
+ sampleHz: number;
124
+ }
38
125
  export interface CaptureResult {
39
126
  /** Unique device-generated UUID */
40
127
  deviceUuid: string;
@@ -484,10 +484,41 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
484
484
  Int32(config.videoFormat.framesPerSecond))
485
485
  isRunning = true
486
486
  currentTrackingState = .initialising
487
+
488
+ // v0.8.0 Phase 3c — install the worklet runtime + register
489
+ // the first-party stitching callback. The delegate's
490
+ // per-frame ingest now routes through
491
+ // `RNSARWorkletRuntime.dispatchFrame` (see
492
+ // `session(_:didUpdate:)` below) which invokes this
493
+ // callback synchronously. Net behavior is byte-identical
494
+ // to the pre-Phase-3c direct `consumer.consumeFrame(...)`
495
+ // call. The indirection sets up the seam where Phase 4
496
+ // will fan out to host worklets without touching this
497
+ // first-party path.
498
+ RNSARWorkletRuntime.shared().installIfNeeded()
499
+ RNSARWorkletRuntime.shared().setFirstPartyCallback {
500
+ [weak self] arFrame, pose in
501
+ // ARKit pool reuse contract: must consume the pixel
502
+ // buffer before returning. The consumer's
503
+ // `consumeFrame` does that synchronously inside the
504
+ // call (NV12 → cv::Mat sync, then heavy work on its
505
+ // own queue). We're on the same thread as the
506
+ // delegate (ARSession.delegateQueue), so the contract
507
+ // holds end-to-end.
508
+ guard let self = self else { return }
509
+ guard let consumer = self.incrementalConsumer else { return }
510
+ consumer.consumeFrame(pixelBuffer: arFrame.capturedImage,
511
+ pose: pose)
512
+ }
487
513
  }
488
514
 
489
515
  @objc public func stop() {
490
516
  guard isRunning else { return }
517
+ // v0.8.0 Phase 3c — drop the first-party callback so the
518
+ // closure's `[weak self]` reference can be released
519
+ // immediately + no in-flight delegate frame re-enters the
520
+ // engine after stop. Idempotent.
521
+ RNSARWorkletRuntime.shared().setFirstPartyCallback(nil)
491
522
  arSession.pause()
492
523
  isRunning = false
493
524
  currentTrackingState = .notAvailable
@@ -561,16 +592,21 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
561
592
  }
562
593
  }
563
594
 
564
- // Deliver this frame to the live incremental-stitching
565
- // consumer if one is registered. The consumer MUST consume
566
- // the pixel buffer before returning (Apple's ARKit pool
567
- // reuse contract same constraint as the recording-append
568
- // path below) `IncrementalStitcher` does this by
569
- // converting NV12 cv::Mat synchronously inside the call,
570
- // then doing the heavy work on its own queue.
571
- if let consumer = self.incrementalConsumer {
572
- consumer.consumeFrame(pixelBuffer: frame.capturedImage, pose: pose)
573
- }
595
+ // v0.8.0 Phase 3c route the per-frame ingest through the
596
+ // worklet runtime instead of calling the consumer directly.
597
+ // The first-party callback (installed in `start()` above)
598
+ // wraps the same `consumer.consumeFrame(pixelBuffer:pose:)`
599
+ // call path, so net behavior is byte-identical to v0.7.x.
600
+ // The indirection sets up the seam where Phase 4 will fan
601
+ // out to host worklets (registered via the v0.8.0
602
+ // `useFrameProcessor` TS hook + a JSI plugin entry point)
603
+ // without changing this first-party path.
604
+ //
605
+ // ARKit pool reuse contract: still satisfied — the runtime
606
+ // invokes the callback synchronously on the delegate
607
+ // thread, and the callback's `consumer.consumeFrame(...)`
608
+ // does the same NV12 → cv::Mat sync conversion as before.
609
+ RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
574
610
 
575
611
  // If recording is in flight, append this frame to the
576
612
  // asset writer DIRECTLY — no queue hop.