react-native-image-stitcher 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,125 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.9.0] — 2026-05-27
20
+
21
+ ### Added — layered frame-access helpers
22
+
23
+ Three new primitives completing the Tier 2 surface in the
24
+ three-tier extensibility pattern. See `docs/frame-access-tiers.md`
25
+ for the full decision flow + use-case mapping.
26
+
27
+ #### Layer 1 — `save_frame_as_jpeg` vc Frame Processor plugin (native)
28
+
29
+ Worklet-callable JPEG encoder. Registers on both platforms:
30
+
31
+ - **iOS** — `SaveFrameAsJpegPlugin.mm` (CIImage → CGImage → UIImage
32
+ → UIImageJPEGRepresentation → atomic NSData write). Registered
33
+ via `+ (void)load` hook into `FrameProcessorPluginRegistry`.
34
+ - **Android** — `SaveFrameAsJpegPlugin.kt` wrapping the lib's
35
+ existing `YuvImageConverter.encodeJpegFromNV21` encoder (the
36
+ same one used by `RNSARCameraView`'s keyframe-accept callback).
37
+ Registered alongside `cv_flow_gate_process_frame` in
38
+ `RNImageStitcherPackage.ensureFrameProcessorPluginRegistered`.
39
+
40
+ Plugin contract (identical on both platforms):
41
+ - Args: `path` (string, REQUIRED), `quality` (number 0-100,
42
+ default 75, clamped `[1, 100]`)
43
+ - Returns: `{ ok: true, path, width, height }` OR
44
+ `{ ok: false, error: "..." }`
45
+
46
+ Hosts can call this directly from their own `useFrameProcessor`
47
+ worklet for custom rate-control logic; most consumers use it
48
+ indirectly via Layer 3.
49
+
50
+ #### Layer 2 — `useThrottledFrameProcessor` hook
51
+
52
+ ```tsx
53
+ const fp = useThrottledFrameProcessor(
54
+ (frame) => {
55
+ 'worklet';
56
+ // Worklet-native processing at sub-frame-rate
57
+ },
58
+ { sampleHz: 2 },
59
+ [],
60
+ );
61
+ ```
62
+
63
+ Pure TS throttle gate over `useFrameProcessor` (v0.8.0). Worklet
64
+ fires up to `sampleHz` times per second; ticks too close together
65
+ dropped via a monotonic-time `useSharedValue` gate.
66
+
67
+ **Use for**: worklet-native processing — native OCR via
68
+ Vision.framework / ML Kit wrapped as vc Frame Processor plugins,
69
+ TFLite ML inference, LiDAR depth (`frame.arDepth`). Direct
70
+ buffer/pose/depth access in the worklet; bridge small bbox-result
71
+ payloads to JS via `runOnJS`.
72
+
73
+ `sampleHz` clamped to `[0.5, 30]`.
74
+
75
+ #### Layer 3 — `useFrameStream` hook
76
+
77
+ ```tsx
78
+ const fp = useFrameStream(
79
+ { sampleHz: 2, quality: 75 },
80
+ (sample) => {
81
+ // JS-thread callback: sample.jpegPath, sample.pose, sample.timestamp
82
+ setThumbnail(sample.jpegPath);
83
+ },
84
+ );
85
+ ```
86
+
87
+ Composes Layer 2 + Layer 1 + `runOnJS` bridge to deliver
88
+ `SampledFrame` objects to a JS-thread handler. Slot-reuse
89
+ strategy bounds disk usage to ~4 stale JPEGs.
90
+
91
+ **Use for**: JS-thread consumers — file-path OCR libraries (RN
92
+ modules wrapping ML Kit), cloud upload, thumbnail preview UI,
93
+ JS-side ML (TF.js, transformers.js).
94
+
95
+ `sampleHz` clamped to `[0.5, 10]`; `quality` clamped `[1, 100]`.
96
+
97
+ #### Types
98
+
99
+ - `SampledFrame` — `{ jpegPath, pose, timestamp, width, height }`
100
+ - `FrameStreamOptions` — `{ sampleHz, quality?, outputDir? }`
101
+ - `ThrottledFrameProcessorOptions` — `{ sampleHz }`
102
+
103
+ All exported from `react-native-image-stitcher`.
104
+
105
+ ### Documentation
106
+
107
+ - `docs/frame-access-tiers.md` — new comprehensive reference for
108
+ all four host-facing hooks (`useKeyframeStream`,
109
+ `useThrottledFrameProcessor`, `useFrameStream`,
110
+ `useFrameProcessor`) with decision flow, cost envelope, use-case
111
+ mapping, AR vs non-AR mode tradeoff.
112
+
113
+ ### Example app
114
+
115
+ `example/App.tsx` now mounts `useFrameStream` at 2 Hz with a
116
+ visible thumbnail overlay (bottom-right corner) — visual proof of
117
+ the Layer 1 + 2 + 3 pipeline working end-to-end on both iPhone
118
+ (60 Hz AR) and Galaxy A35 (30 Hz AR).
119
+
120
+ ### Compatibility
121
+
122
+ - Strict additive over v0.8.0. No host changes required.
123
+ - Works in both AR and non-AR modes via v0.8.0's unified
124
+ `useFrameProcessor`.
125
+ - New hooks return `useFrameProcessor`-shape objects compatible
126
+ with `<Camera frameProcessor={...}>` (Phase 5 from v0.8.0).
127
+
128
+ ### Notes
129
+
130
+ - Formal SSIM parity gate (Phase 7 of the v0.9.0 plan) was NOT
131
+ run for this release — the layered design doesn't touch
132
+ first-party stitching, so a regression is structurally unlikely.
133
+ Harness still in place from v0.8.0 (`scripts/ssim-compare.py`)
134
+ for any host that wants to run it locally.
135
+
136
+ [0.9.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.8.0...v0.9.0
137
+
19
138
  ## [0.8.0] — 2026-05-27
20
139
 
21
140
  ### Added — `useFrameProcessor` hook for host worklets
@@ -55,19 +55,33 @@ class RNImageStitcherPackage : ReactPackage {
55
55
  ) { proxy, options ->
56
56
  CvFlowGateFrameProcessor(proxy, options)
57
57
  }
58
+ // v0.9.0 Layer 1 — register `save_frame_as_jpeg`
59
+ // alongside the cv_flow_gate plugin. Same lifecycle,
60
+ // same defensive error handling (the outer try/catch
61
+ // covers both registrations). Either both register
62
+ // or neither does — if vc isn't on the classpath,
63
+ // both calls are skipped together.
64
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin(
65
+ SaveFrameAsJpegPlugin.PLUGIN_NAME,
66
+ ) { proxy, options ->
67
+ SaveFrameAsJpegPlugin(proxy, options)
68
+ }
58
69
  fpPluginRegistered = true
59
70
  } catch (e: NoClassDefFoundError) {
60
71
  android.util.Log.i(
61
72
  "RNImageStitcherPackage",
62
73
  "vision-camera FrameProcessorPluginRegistry not on classpath — "
63
- + "skipping cv_flow_gate_process_frame plugin registration "
64
- + "(host app doesn't appear to use Frame Processors).",
74
+ + "skipping cv_flow_gate_process_frame + save_frame_as_jpeg "
75
+ + "plugin registration (host app doesn't appear to use "
76
+ + "Frame Processors).",
65
77
  )
66
78
  fpPluginRegistered = true // don't retry every package init
67
79
  } catch (e: Throwable) {
68
80
  android.util.Log.w(
69
81
  "RNImageStitcherPackage",
70
- "Failed to register cv_flow_gate_process_frame plugin: ${e.message}",
82
+ "Failed to register Frame Processor plugins "
83
+ + "(cv_flow_gate_process_frame / save_frame_as_jpeg): "
84
+ + e.message,
71
85
  )
72
86
  fpPluginRegistered = true
73
87
  }
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.graphics.ImageFormat
5
+ import android.media.Image
6
+ import android.util.Log
7
+ import androidx.annotation.Keep
8
+ import com.facebook.proguard.annotations.DoNotStrip
9
+ import com.mrousavy.camera.frameprocessors.Frame
10
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
11
+ import com.mrousavy.camera.frameprocessors.VisionCameraProxy
12
+ import io.imagestitcher.rn.ar.YuvImageConverter
13
+
14
+ /**
15
+ * v0.9.0 Layer 1 — Android vc Frame Processor plugin that JPEG-
16
+ * encodes the supplied frame to a host-supplied path. Mirror of
17
+ * iOS' `SaveFrameAsJpegPlugin.mm`.
18
+ *
19
+ * Plugin name (must match iOS): `save_frame_as_jpeg`.
20
+ *
21
+ * ## Wrapping the existing encoder
22
+ *
23
+ * The lib already encodes JPEGs from NV21 bytes via
24
+ * `YuvImageConverter.encodeJpegFromNV21` — that's the path
25
+ * `RNSARCameraView.kt`'s keyframe-accept callback uses (line 589,
26
+ * `onAccept = { targetPath -> ... encodeJpegFromNV21(...) }`).
27
+ * This plugin reuses that exact encoder so:
28
+ * - JPEG output is byte-equivalent to the keyframe-accept output
29
+ * (same encoder, same quality knob)
30
+ * - No new encoder maintenance burden
31
+ *
32
+ * vision-camera's `Frame.image` is an `android.media.Image` in
33
+ * `YUV_420_888` format (the camera's native). We pass it through
34
+ * `YuvImageConverter.packNV21(image)` to extract the dense NV21
35
+ * byte array + dims, then `encodeJpegFromNV21(packed, file, q, rot)`
36
+ * does the JPEG write.
37
+ *
38
+ * ## Plugin contract (matches iOS surface exactly)
39
+ *
40
+ * Arguments dict:
41
+ * - `path` (string, REQUIRED): absolute output path.
42
+ * - `quality` (number, optional): 0-100 JPEG quality. Default 75.
43
+ * Clamped to [1, 100].
44
+ *
45
+ * Returns:
46
+ * - On success: `{ "ok" => true, "path" => ..., "width" => ...,
47
+ * "height" => ... }`
48
+ * - On failure: `{ "ok" => false, "error" => "..." }`
49
+ *
50
+ * Errors surfaced via the result map (not thrown) — host worklets
51
+ * can branch on `result.ok` without try/catch. Same convention
52
+ * as iOS.
53
+ *
54
+ * ## Lifetime / threading
55
+ *
56
+ * The supplied `Frame` (and its `Image`) is valid only for the
57
+ * duration of this callback — vision-camera closes the underlying
58
+ * `Image` on return. All Image access (NV21 pack + JPEG encode)
59
+ * happens synchronously inside `callback()`.
60
+ *
61
+ * ## Format restriction
62
+ *
63
+ * Only `YUV_420_888` input is supported (vc Android's standard).
64
+ * Anything else returns `{ ok: false, error: "unsupported format" }`.
65
+ * No format conversion fallback — that would mask bugs in the host
66
+ * camera config.
67
+ *
68
+ * ## Registration
69
+ *
70
+ * Registered in `RNImageStitcherPackage.kt`'s companion-object
71
+ * `ensureFrameProcessorPluginRegistered()`, alongside
72
+ * `cv_flow_gate_process_frame`. Same defensive
73
+ * NoClassDefFoundError handling — if vc isn't on the host's
74
+ * classpath, registration is silently skipped (and the plugin's
75
+ * `init { … }` calls below never happen).
76
+ */
77
+ @DoNotStrip
78
+ @Keep
79
+ class SaveFrameAsJpegPlugin(
80
+ @Suppress("UNUSED_PARAMETER") proxy: VisionCameraProxy,
81
+ @Suppress("UNUSED_PARAMETER") options: Map<String, Any>?,
82
+ ) : FrameProcessorPlugin() {
83
+
84
+ override fun callback(frame: Frame, params: Map<String, Any>?): Any {
85
+ val path = params?.get("path") as? String
86
+ ?: return mapOf(
87
+ "ok" to false,
88
+ "error" to "missing required `path` argument",
89
+ )
90
+ val rawQuality = (params["quality"] as? Number)?.toInt() ?: 75
91
+ val quality = rawQuality.coerceIn(1, 100)
92
+
93
+ // Frame may throw if vc already released it.
94
+ val image: Image = try {
95
+ frame.image
96
+ } catch (e: Throwable) {
97
+ return mapOf(
98
+ "ok" to false,
99
+ "error" to "frame invalid: ${e.message}",
100
+ )
101
+ }
102
+
103
+ if (image.format != ImageFormat.YUV_420_888) {
104
+ return mapOf(
105
+ "ok" to false,
106
+ "error" to "unsupported format ${image.format} (need YUV_420_888)",
107
+ )
108
+ }
109
+
110
+ // Pack NV21 — same call site RNSARCameraView uses.
111
+ val packed = YuvImageConverter.packNV21(image)
112
+ ?: return mapOf(
113
+ "ok" to false,
114
+ "error" to "YuvImageConverter.packNV21 returned null",
115
+ )
116
+
117
+ // Reuse the lib's existing JPEG encoder. Rotation is 0 here
118
+ // (the host's frame is in camera-native orientation; if they
119
+ // want display orientation they can pass an `orientation`
120
+ // arg in a future version — for v0.9.0 the worklet emits
121
+ // raw-camera-oriented JPEGs, matching the keyframe-accept
122
+ // pipeline's behaviour).
123
+ //
124
+ // Signature: `encodeJpegFromNV21(packed, outputPath: String,
125
+ // jpegQuality: Int, displayRotation: Int): String?` — returns
126
+ // the written path on success, null on failure.
127
+ val encodedPath: String? = try {
128
+ YuvImageConverter.encodeJpegFromNV21(
129
+ packed,
130
+ path,
131
+ jpegQuality = quality,
132
+ displayRotation = 0,
133
+ )
134
+ } catch (e: Throwable) {
135
+ return mapOf(
136
+ "ok" to false,
137
+ "error" to "encodeJpegFromNV21 threw: ${e.message}",
138
+ )
139
+ }
140
+ if (encodedPath == null) {
141
+ return mapOf(
142
+ "ok" to false,
143
+ "error" to "encodeJpegFromNV21 returned null",
144
+ )
145
+ }
146
+
147
+ return mapOf(
148
+ "ok" to true,
149
+ "path" to encodedPath,
150
+ "width" to packed.width,
151
+ "height" to packed.height,
152
+ )
153
+ }
154
+
155
+ companion object {
156
+ private const val TAG = "SaveFrameAsJpegPlugin"
157
+
158
+ /// Plugin name; MUST match iOS + the JS-side
159
+ /// `initFrameProcessorPlugin('save_frame_as_jpeg')` call.
160
+ const val PLUGIN_NAME = "save_frame_as_jpeg"
161
+ }
162
+ }
package/dist/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
67
67
  export { useKeyframeStream } from './stitching/useKeyframeStream';
68
68
  export type { StitcherFrame, StitcherFrameProcessor, ARAnchor, } from './stitching/StitcherFrame';
69
69
  export { useFrameProcessor } from './stitching/useFrameProcessor';
70
+ export { useThrottledFrameProcessor } from './stitching/useThrottledFrameProcessor';
71
+ export type { ThrottledFrameProcessorOptions } from './types';
72
+ export { useFrameStream } from './stitching/useFrameStream';
73
+ export type { FrameStreamOptions, SampledFrame } from './types';
70
74
  export { useFrameProcessorDriver } from './stitching/useFrameProcessorDriver';
71
75
  export type { UseFrameProcessorDriverOptions, FrameProcessorDriverHandle, } from './stitching/useFrameProcessorDriver';
72
76
  export { stitchVideo } from './stitching/stitchVideo';
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.useFrameProcessorDriver = exports.useFrameProcessor = exports.useKeyframeStream = 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;
25
+ exports.stitchVideo = exports.useFrameProcessorDriver = exports.useFrameStream = exports.useThrottledFrameProcessor = exports.useFrameProcessor = exports.useKeyframeStream = 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
  // ─────────────────────────────────────────────────────────────────────
@@ -155,6 +155,25 @@ Object.defineProperty(exports, "useKeyframeStream", { enumerable: true, get: fun
155
155
  // See the hook's docstring + StitcherFrame.ts for the contract.
156
156
  var useFrameProcessor_1 = require("./stitching/useFrameProcessor");
157
157
  Object.defineProperty(exports, "useFrameProcessor", { enumerable: true, get: function () { return useFrameProcessor_1.useFrameProcessor; } });
158
+ // v0.9.0 Layer 2 — `useThrottledFrameProcessor`. Throttle gate over
159
+ // `useFrameProcessor` for sub-frame-rate worklet-native processing
160
+ // (native OCR via Vision.framework / ML Kit, TFLite ML detection,
161
+ // LiDAR depth). The worklet runtime has direct access to
162
+ // `frame.toArrayBuffer()` / `frame.arDepth`; bridge small payloads
163
+ // (bboxes, depth-derived metrics) to JS via `runOnJS`. For JS-thread
164
+ // JPEG consumers (file-path OCR libs, cloud upload, thumbnail UI),
165
+ // prefer `useFrameStream` (Layer 3, ships in the same release).
166
+ var useThrottledFrameProcessor_1 = require("./stitching/useThrottledFrameProcessor");
167
+ Object.defineProperty(exports, "useThrottledFrameProcessor", { enumerable: true, get: function () { return useThrottledFrameProcessor_1.useThrottledFrameProcessor; } });
168
+ // v0.9.0 Layer 3 — `useFrameStream`. JS-thread sampled-frame
169
+ // stream over Layer 1 (`save_frame_as_jpeg` vc plugin) + Layer 2
170
+ // (`useThrottledFrameProcessor`). Use for JS-thread consumers:
171
+ // file-path OCR libs (RN modules), cloud upload, thumbnail UI.
172
+ // For worklet-native processing (Vision/ML Kit as vc plugins,
173
+ // TFLite ML, LiDAR depth), prefer `useThrottledFrameProcessor`
174
+ // (Layer 2) — lower latency, no JPEG roundtrip.
175
+ var useFrameStream_1 = require("./stitching/useFrameStream");
176
+ Object.defineProperty(exports, "useFrameStream", { enumerable: true, get: function () { return useFrameStream_1.useFrameStream; } });
158
177
  // vision-camera Frame Processor driver for non-AR captures. As
159
178
  // of v0.6 the only non-AR driver exported (the legacy
160
179
  // `useIncrementalJSDriver` was removed; was deprecated in v0.5).
@@ -0,0 +1,34 @@
1
+ import { useThrottledFrameProcessor } from './useThrottledFrameProcessor';
2
+ import type { FrameStreamOptions, SampledFrame } from '../types';
3
+ /**
4
+ * `useFrameStream` — Layer 3. See module docstring for the full
5
+ * design + use-case mapping. Quick start:
6
+ *
7
+ * ```tsx
8
+ * import { Camera, useFrameStream } from 'react-native-image-stitcher';
9
+ *
10
+ * function MyScreen() {
11
+ * const fp = useFrameStream(
12
+ * { sampleHz: 2, quality: 75 },
13
+ * (sample) => {
14
+ * setThumbnail(sample.jpegPath);
15
+ * },
16
+ * );
17
+ * return <Camera frameProcessor={fp} ... />;
18
+ * }
19
+ * ```
20
+ *
21
+ * @param options `{ sampleHz, quality?, outputDir? }`. `sampleHz`
22
+ * clamped to `[0.5, 10]`.
23
+ * @param handler JS-thread callback fired per sample. Receives a
24
+ * `SampledFrame`. May return a Promise; rejections
25
+ * are caught + logged (not re-thrown) so one
26
+ * misbehaving handler doesn't break the stream.
27
+ *
28
+ * @returns A `useFrameProcessor`-shaped processor object — pass to
29
+ * `<Camera frameProcessor={...}>` for non-AR mode wiring.
30
+ * (AR mode auto-registration via `__stitcherProxy` is
31
+ * handled inside `useFrameProcessor`.)
32
+ */
33
+ export declare function useFrameStream(options: FrameStreamOptions, handler: (sample: SampledFrame) => void | Promise<void>): ReturnType<typeof useThrottledFrameProcessor>;
34
+ //# sourceMappingURL=useFrameStream.d.ts.map
@@ -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