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.
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // stitcher_frame_data_test.cpp — v0.10.0 audit #9A
4
+ //
5
+ // Sanity coverage for the `StitcherFrameData` POD payload + the
6
+ // `PixelBufferReader` interface contract. The shared C++
7
+ // `StitcherFrameData` is constructed by both the iOS Obj-C++ side
8
+ // (`StitcherFrameHostObject.mm`) and the Android JNI side
9
+ // (`stitcher_frame_jni.cpp`); these tests pin the default-construction
10
+ // invariants both sides depend on (e.g. `hasTranslation=false`,
11
+ // `qw=1.0`).
12
+ //
13
+ // The `PixelBufferReader` tests use a fake-buffer implementation
14
+ // (`FakePixelBufferReader`) to validate the `copyTo` clipping
15
+ // behaviour the docstring promises.
16
+
17
+ #include "stitcher_frame_data.hpp"
18
+
19
+ #include <gtest/gtest.h>
20
+
21
+ #include <cstdint>
22
+ #include <cstring>
23
+ #include <memory>
24
+ #include <vector>
25
+
26
+ using retailens::PixelBufferReader;
27
+ using retailens::StitcherFrameData;
28
+
29
+ namespace {
30
+
31
+ /// Minimal fake reader — backs a fixed byte vector. Used by the
32
+ /// `PixelBufferReader` contract tests below to verify
33
+ /// `copyTo` clips at the smaller of (maxBytes, byteSize).
34
+ class FakePixelBufferReader : public PixelBufferReader {
35
+ public:
36
+ explicit FakePixelBufferReader(std::vector<uint8_t> bytes)
37
+ : _bytes(std::move(bytes)) {}
38
+
39
+ std::size_t byteSize() const override { return _bytes.size(); }
40
+
41
+ std::size_t copyTo(uint8_t* dst, std::size_t maxBytes) override {
42
+ const std::size_t n =
43
+ maxBytes < _bytes.size() ? maxBytes : _bytes.size();
44
+ std::memcpy(dst, _bytes.data(), n);
45
+ return n;
46
+ }
47
+
48
+ private:
49
+ std::vector<uint8_t> _bytes;
50
+ };
51
+
52
+ } // namespace
53
+
54
+ // ─── StitcherFrameData default-construction invariants ─────────────
55
+
56
+ TEST(StitcherFrameDataTest, DefaultsAreSafeForJSIDispatch) {
57
+ // The JSI host object's `get()` dispatch keys off these defaults to
58
+ // expose `undefined` for unset fields. In particular:
59
+ // - `hasTranslation == false` → pose.translation === undefined
60
+ // - `arTrackingState.empty()` → arTrackingState === undefined
61
+ // - `qw == 1.0` with rest zero → identity rotation (safe default
62
+ // for non-AR mode where rotation is unknown)
63
+ StitcherFrameData d;
64
+ EXPECT_EQ(d.width, 0);
65
+ EXPECT_EQ(d.height, 0);
66
+ EXPECT_TRUE(d.source.empty());
67
+ EXPECT_TRUE(d.pixelFormat.empty());
68
+ EXPECT_TRUE(d.orientation.empty());
69
+ EXPECT_DOUBLE_EQ(d.timestampNs, 0.0);
70
+ EXPECT_DOUBLE_EQ(d.qx, 0.0);
71
+ EXPECT_DOUBLE_EQ(d.qy, 0.0);
72
+ EXPECT_DOUBLE_EQ(d.qz, 0.0);
73
+ EXPECT_DOUBLE_EQ(d.qw, 1.0); // identity rotation
74
+ EXPECT_DOUBLE_EQ(d.tx, 0.0);
75
+ EXPECT_DOUBLE_EQ(d.ty, 0.0);
76
+ EXPECT_DOUBLE_EQ(d.tz, 0.0);
77
+ EXPECT_FALSE(d.hasTranslation);
78
+ EXPECT_TRUE(d.arTrackingState.empty());
79
+ EXPECT_EQ(d.pixelReader, nullptr);
80
+ }
81
+
82
+ TEST(StitcherFrameDataTest, IsCopyable) {
83
+ // `StitcherFrameData` is documented as "value-typed (cheap to copy;
84
+ // ~100 bytes)". Copy needs to deep-copy the strings + bump the
85
+ // pixelReader shared_ptr refcount.
86
+ StitcherFrameData a;
87
+ a.source = "ar";
88
+ a.width = 1920;
89
+ a.height = 1080;
90
+ a.pixelReader = std::make_shared<FakePixelBufferReader>(
91
+ std::vector<uint8_t>{1, 2, 3});
92
+
93
+ StitcherFrameData b = a;
94
+ EXPECT_EQ(b.source, "ar");
95
+ EXPECT_EQ(b.width, 1920);
96
+ EXPECT_EQ(b.height, 1080);
97
+ ASSERT_NE(b.pixelReader, nullptr);
98
+ EXPECT_EQ(b.pixelReader.use_count(), 2); // both a and b hold a ref
99
+ EXPECT_EQ(b.pixelReader->byteSize(), 3u);
100
+ }
101
+
102
+ // ─── PixelBufferReader contract ────────────────────────────────────
103
+
104
+ TEST(PixelBufferReaderTest, CopyToReturnsAllBytesWhenMaxBytesExceedsSize) {
105
+ FakePixelBufferReader reader({0x11, 0x22, 0x33});
106
+ uint8_t buf[8] = {0};
107
+ const std::size_t written = reader.copyTo(buf, sizeof(buf));
108
+ EXPECT_EQ(written, 3u);
109
+ EXPECT_EQ(buf[0], 0x11);
110
+ EXPECT_EQ(buf[1], 0x22);
111
+ EXPECT_EQ(buf[2], 0x33);
112
+ EXPECT_EQ(buf[3], 0u); // untouched tail
113
+ }
114
+
115
+ TEST(PixelBufferReaderTest, CopyToClipsWhenMaxBytesIsSmaller) {
116
+ // Contract per stitcher_frame_data.hpp: "Implementations MUST handle
117
+ // the case where maxBytes < byteSize() (clip silently)."
118
+ FakePixelBufferReader reader({0xAA, 0xBB, 0xCC, 0xDD});
119
+ uint8_t buf[2] = {0};
120
+ const std::size_t written = reader.copyTo(buf, sizeof(buf));
121
+ EXPECT_EQ(written, 2u);
122
+ EXPECT_EQ(buf[0], 0xAA);
123
+ EXPECT_EQ(buf[1], 0xBB);
124
+ }
125
+
126
+ TEST(PixelBufferReaderTest, CopyToWithZeroMaxBytesReturnsZero) {
127
+ FakePixelBufferReader reader({0x01, 0x02, 0x03});
128
+ uint8_t dummy = 0xFF;
129
+ const std::size_t written = reader.copyTo(&dummy, 0);
130
+ EXPECT_EQ(written, 0u);
131
+ EXPECT_EQ(dummy, 0xFF); // dst untouched when maxBytes == 0
132
+ }
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // stitcher_worklet_registry_test.cpp — v0.10.0 audit #9A
4
+ //
5
+ // Lifecycle + concurrency coverage for the process-scope native
6
+ // `StitcherWorkletRegistry` introduced in v0.8.0 Phase 4b. See
7
+ // `cpp/stitcher_worklet_registry.hpp` for the public contract.
8
+ //
9
+ // The registry's production `install(runtime, workletValue)` path
10
+ // constructs an `RNWorklet::WorkletInvoker` from a JSI `Runtime` and
11
+ // `Value`. Standing up a real worklets-core runtime under gtest would
12
+ // pull in Hermes + JSI + the whole worklets-core library — too heavy
13
+ // for these scope-limited storage tests. Instead we exercise the
14
+ // equivalent `_installEntryForTests(invoker)` test seam (mirrors
15
+ // `_resetForTests` in pattern) with `nullptr` invokers. The registry
16
+ // never dereferences the pointer — it only stores the shared_ptr and
17
+ // hands it back via `snapshot` — so nullptr is safe.
18
+ //
19
+ // What this covers (lifting from the production `install`/`uninstall`/
20
+ // `snapshot`/`count` contract):
21
+ // - shared() returns the same instance across calls
22
+ // - count/snapshot start empty after _resetForTests
23
+ // - install assigns monotonically increasing host-N IDs
24
+ // - count tracks installs / uninstalls
25
+ // - uninstall of an unknown ID is a no-op (matches JS side)
26
+ // - snapshot returns an independent copy (mutations after snapshot
27
+ // don't affect the snapshot's view)
28
+ // - concurrent installs from many threads serialise correctly and
29
+ // yield unique IDs (no double-issue under contention)
30
+
31
+ #include "stitcher_worklet_registry.hpp"
32
+ #include <react-native-worklets-core/WKTJsiWorklet.h>
33
+
34
+ #include <gtest/gtest.h>
35
+
36
+ #include <algorithm>
37
+ #include <atomic>
38
+ #include <chrono>
39
+ #include <set>
40
+ #include <string>
41
+ #include <thread>
42
+ #include <vector>
43
+
44
+ using retailens::StitcherWorkletEntry;
45
+ using retailens::StitcherWorkletRegistry;
46
+
47
+ namespace {
48
+
49
+ /// Test fixture — resets the singleton before each test so cases
50
+ /// don't leak state into each other. Without this every test would
51
+ /// see the cumulative entries from prior tests in the run.
52
+ class StitcherWorkletRegistryTest : public ::testing::Test {
53
+ protected:
54
+ void SetUp() override {
55
+ StitcherWorkletRegistry::shared()._resetForTests();
56
+ }
57
+ void TearDown() override {
58
+ StitcherWorkletRegistry::shared()._resetForTests();
59
+ }
60
+ };
61
+
62
+ } // namespace
63
+
64
+ TEST_F(StitcherWorkletRegistryTest, SharedReturnsSameInstance) {
65
+ // The singleton invariant is load-bearing for the JS-side mental
66
+ // model: `useFrameProcessor` mounts on one component and unmounts
67
+ // from another but the registry stays.
68
+ StitcherWorkletRegistry& a = StitcherWorkletRegistry::shared();
69
+ StitcherWorkletRegistry& b = StitcherWorkletRegistry::shared();
70
+ EXPECT_EQ(&a, &b);
71
+ }
72
+
73
+ TEST_F(StitcherWorkletRegistryTest, StartsEmptyAfterReset) {
74
+ auto& r = StitcherWorkletRegistry::shared();
75
+ EXPECT_EQ(r.count(), 0u);
76
+ EXPECT_TRUE(r.snapshot().empty());
77
+ }
78
+
79
+ TEST_F(StitcherWorkletRegistryTest, InstallAssignsHostPrefixedIncrementingIds) {
80
+ auto& r = StitcherWorkletRegistry::shared();
81
+ const std::string id0 = r._installEntryForTests(nullptr);
82
+ const std::string id1 = r._installEntryForTests(nullptr);
83
+ const std::string id2 = r._installEntryForTests(nullptr);
84
+
85
+ // IDs are "host-N" with N monotonically increasing from 0 after a
86
+ // reset. The format is part of the public contract — uninstall
87
+ // callers store these IDs verbatim.
88
+ EXPECT_EQ(id0, "host-0");
89
+ EXPECT_EQ(id1, "host-1");
90
+ EXPECT_EQ(id2, "host-2");
91
+ EXPECT_EQ(r.count(), 3u);
92
+ }
93
+
94
+ TEST_F(StitcherWorkletRegistryTest, UninstallRemovesByIdAndUpdatesCount) {
95
+ auto& r = StitcherWorkletRegistry::shared();
96
+ const std::string id0 = r._installEntryForTests(nullptr);
97
+ const std::string id1 = r._installEntryForTests(nullptr);
98
+ const std::string id2 = r._installEntryForTests(nullptr);
99
+ EXPECT_EQ(r.count(), 3u);
100
+
101
+ r.uninstall(id1); // remove the middle entry
102
+ EXPECT_EQ(r.count(), 2u);
103
+
104
+ // Snapshot should contain only id0 and id2, in some order — the
105
+ // contract doesn't promise insertion order survives uninstall
106
+ // (the erase-remove pattern is stable in practice but we don't
107
+ // pin that publicly).
108
+ const auto snap = r.snapshot();
109
+ std::vector<std::string> remainingIds;
110
+ remainingIds.reserve(snap.size());
111
+ for (const auto& entry : snap) {
112
+ remainingIds.push_back(entry.id);
113
+ }
114
+ std::sort(remainingIds.begin(), remainingIds.end());
115
+ EXPECT_EQ(remainingIds, (std::vector<std::string>{id0, id2}));
116
+ }
117
+
118
+ TEST_F(StitcherWorkletRegistryTest, UninstallOfUnknownIdIsNoop) {
119
+ // Matches the JS-side `StitcherWorkletRegistry.uninstall` semantics
120
+ // (idempotent, no throw on unknown). Critical because the JS
121
+ // useEffect cleanup can fire after an unmount/remount race where the
122
+ // ID has already been removed.
123
+ auto& r = StitcherWorkletRegistry::shared();
124
+ r._installEntryForTests(nullptr);
125
+ EXPECT_EQ(r.count(), 1u);
126
+ EXPECT_NO_THROW(r.uninstall("host-does-not-exist"));
127
+ EXPECT_NO_THROW(r.uninstall(""));
128
+ EXPECT_EQ(r.count(), 1u); // existing entry untouched
129
+ }
130
+
131
+ TEST_F(StitcherWorkletRegistryTest, SnapshotIsIndependentOfFutureMutations) {
132
+ // Per header docstring: "mutations against the registry after
133
+ // `snapshot` returns do not affect the snapshot." This matters for
134
+ // the AR-session dispatch path, which snapshots and then iterates
135
+ // without holding the registry lock — concurrent uninstall on the
136
+ // JS thread must NOT invalidate the snapshot.
137
+ auto& r = StitcherWorkletRegistry::shared();
138
+ const std::string id0 = r._installEntryForTests(nullptr);
139
+ const std::string id1 = r._installEntryForTests(nullptr);
140
+
141
+ const auto snap = r.snapshot();
142
+ ASSERT_EQ(snap.size(), 2u);
143
+
144
+ r.uninstall(id0);
145
+ r.uninstall(id1);
146
+ EXPECT_EQ(r.count(), 0u);
147
+
148
+ // Snapshot still has both entries.
149
+ EXPECT_EQ(snap.size(), 2u);
150
+ EXPECT_EQ(snap[0].id, id0);
151
+ EXPECT_EQ(snap[1].id, id1);
152
+ }
153
+
154
+ TEST_F(StitcherWorkletRegistryTest, ConcurrentInstallsYieldUniqueIds) {
155
+ // Many threads racing `_installEntryForTests` simultaneously must
156
+ // not see duplicate IDs (would indicate the mutex around _nextId is
157
+ // missing or broken). This is the per-instance equivalent of
158
+ // TransferredNV21Test's "only one of N concurrent callers wins"
159
+ // pattern, adapted for "all N callers succeed but produce distinct
160
+ // IDs".
161
+ auto& r = StitcherWorkletRegistry::shared();
162
+ constexpr int kThreads = 16;
163
+ constexpr int kPerThread = 32; // 512 total installs
164
+
165
+ std::vector<std::thread> workers;
166
+ workers.reserve(kThreads);
167
+ std::vector<std::vector<std::string>> idsPerThread(kThreads);
168
+
169
+ std::atomic<bool> go{false};
170
+ for (int t = 0; t < kThreads; ++t) {
171
+ workers.emplace_back([&, t]() {
172
+ while (!go.load(std::memory_order_acquire)) {
173
+ std::this_thread::yield();
174
+ }
175
+ for (int i = 0; i < kPerThread; ++i) {
176
+ idsPerThread[t].push_back(r._installEntryForTests(nullptr));
177
+ }
178
+ });
179
+ }
180
+ go.store(true, std::memory_order_release);
181
+ for (auto& w : workers) w.join();
182
+
183
+ // Aggregate every ID issued. Total count = kThreads * kPerThread;
184
+ // uniqueness = the set size matches.
185
+ std::set<std::string> allIds;
186
+ for (const auto& v : idsPerThread) {
187
+ for (const auto& id : v) {
188
+ allIds.insert(id);
189
+ }
190
+ }
191
+ EXPECT_EQ(allIds.size(),
192
+ static_cast<std::size_t>(kThreads * kPerThread));
193
+ EXPECT_EQ(r.count(),
194
+ static_cast<std::size_t>(kThreads * kPerThread));
195
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // jsi.h — TEST-ONLY stub of facebook::jsi types.
4
+ //
5
+ // The real jsi.h ships with React Native and pulls in a large surface
6
+ // area (Runtime, Value, Object, Function, HostObject, HostFunction,
7
+ // Array, ArrayBuffer, PropNameID, …) along with the build infra to
8
+ // link them. For pure-C++ unit tests that exercise data-structure
9
+ // invariants of code that REFERENCES jsi types but never CALLS into
10
+ // them (e.g. `StitcherWorkletRegistry` storing a `shared_ptr` and
11
+ // forwarding `Runtime&` to a constructor stub), we only need the
12
+ // types to be NAMED so headers compile.
13
+ //
14
+ // Pattern: this stub is placed first on the test target's include
15
+ // path so `#include <jsi/jsi.h>` resolves here instead of to RN's
16
+ // real header. Production builds NEVER see this file — it lives
17
+ // only under `cpp/tests/stubs/`, which is referenced exclusively by
18
+ // `cpp/tests/CMakeLists.txt`.
19
+ //
20
+ // Tests that need to actually CONSTRUCT or CALL into JSI types should
21
+ // not use this stub — they should run against a real JSI runtime (a
22
+ // future v0.11.0+ test target that links Hermes).
23
+
24
+ #pragma once
25
+
26
+ namespace facebook {
27
+ namespace jsi {
28
+
29
+ class Runtime {};
30
+ class Value {};
31
+
32
+ } // namespace jsi
33
+ } // namespace facebook
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // WKTJsiWorklet.h — TEST-ONLY stub of RNWorklet::WorkletInvoker.
4
+ //
5
+ // `cpp/stitcher_worklet_registry.cpp` constructs a
6
+ // `std::make_shared<RNWorklet::WorkletInvoker>(runtime, value)` inside
7
+ // `install`. The real WorkletInvoker (from react-native-worklets-core)
8
+ // captures the worklet's source / closure / runtime affinity and is
9
+ // non-trivial to stand up in a unit-test context.
10
+ //
11
+ // This stub provides JUST the symbols needed for the registry to
12
+ // compile and link. The constructor and destructor are no-ops; calling
13
+ // methods on a stub invoker is undefined behaviour, but the registry
14
+ // itself never does (it only stores the shared_ptr and hands it out
15
+ // via `snapshot`). Tests construct entries directly via
16
+ // `_installEntryForTests(nullptr)` to avoid even the trivial
17
+ // allocation.
18
+ //
19
+ // See cpp/tests/stubs/jsi/jsi.h for the parallel stub of facebook::jsi.
20
+
21
+ #pragma once
22
+
23
+ #include <jsi/jsi.h>
24
+
25
+ namespace RNWorklet {
26
+
27
+ class WorkletInvoker {
28
+ public:
29
+ WorkletInvoker(facebook::jsi::Runtime& /*runtime*/,
30
+ const facebook::jsi::Value& /*workletValue*/) {}
31
+ ~WorkletInvoker() = default;
32
+ };
33
+
34
+ } // namespace RNWorklet
@@ -17,7 +17,7 @@
17
17
  * - This hook does NOT persist captures. Host apps hand the
18
18
  * returned CaptureResult to their own storage layer (WatermelonDB
19
19
  * insert, Redux dispatch, whatever).
20
- * - Video recording lives in useVideoCapture (TODO).
20
+ * - Video recording lives in useVideoCapture.
21
21
  *
22
22
  * The public API is designed to be minimal and replaceable: host apps
23
23
  * that prefer the raw vision-camera API can opt out of this hook and
@@ -19,7 +19,7 @@
19
19
  * - This hook does NOT persist captures. Host apps hand the
20
20
  * returned CaptureResult to their own storage layer (WatermelonDB
21
21
  * insert, Redux dispatch, whatever).
22
- * - Video recording lives in useVideoCapture (TODO).
22
+ * - Video recording lives in useVideoCapture.
23
23
  *
24
24
  * The public API is designed to be minimal and replaceable: host apps
25
25
  * that prefer the raw vision-camera API can opt out of this hook and
@@ -249,6 +249,47 @@ export interface IncrementalState {
249
249
  * keyframes on disk.
250
250
  */
251
251
  refinedPanoramaPath?: string;
252
+ /**
253
+ * v0.10.0 (#15A) — current phase of an in-flight `refinePanorama`
254
+ * call. Fires from both the explicit `module.refinePanorama(...)`
255
+ * JS API path AND the hybrid-engine auto-refine path (which calls
256
+ * the same native refinePanorama internally).
257
+ *
258
+ * Lifecycle:
259
+ * - `"validating"` (fraction 0.05) — synchronous input checks
260
+ * - `"stitching"` (fraction 0.10) — OpenCV stitch in flight
261
+ * - `"writing"` (fraction 0.90) — stitch done, JPEG written
262
+ * - `"done"` (fraction 1.00) — success
263
+ * - `"error"` (fraction 1.00) — failure; `refineError` is set
264
+ *
265
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose mid-pipeline
266
+ * progress, so the 0.10 → 0.90 jump is one opaque step. Use
267
+ * `refineStage` for a stage label; use `refineProgress` purely for
268
+ * spinner progress.
269
+ *
270
+ * Undefined when no refinement is in flight.
271
+ */
272
+ refineStage?: 'validating' | 'stitching' | 'writing' | 'done' | 'error';
273
+ /**
274
+ * v0.10.0 (#15A) — coarse progress fraction in `[0, 1]` aligned
275
+ * with `refineStage`. See `refineStage` for the per-stage value
276
+ * mapping. Undefined when no refinement is in flight.
277
+ */
278
+ refineProgress?: number;
279
+ /**
280
+ * v0.10.0 (#15A) — number of input frames the in-flight refine is
281
+ * processing. Useful for the UI label
282
+ * (`Stitching 6 frames…`). Mirrors the `framesRequested` field
283
+ * returned in the explicit refinePanorama resolution. Undefined
284
+ * when no refinement is in flight.
285
+ */
286
+ refineFrames?: number;
287
+ /**
288
+ * v0.10.0 (#15A) — present only when `refineStage === 'error'`.
289
+ * Human-readable error message; the same text the rejected promise
290
+ * carries. Use to render a one-line failure pill.
291
+ */
292
+ refineError?: string;
252
293
  }
253
294
  export interface IncrementalStartOptions {
254
295
  /**
@@ -61,10 +61,10 @@
61
61
  Object.defineProperty(exports, "__esModule", { value: true });
62
62
  exports.useFrameStream = useFrameStream;
63
63
  const react_1 = require("react");
64
- const react_native_1 = require("react-native");
65
64
  const react_native_vision_camera_1 = require("react-native-vision-camera");
66
65
  const react_native_worklets_core_1 = require("react-native-worklets-core");
67
66
  const useThrottledFrameProcessor_1 = require("./useThrottledFrameProcessor");
67
+ const files_1 = require("../utils/files");
68
68
  /**
69
69
  * `useFrameStream` — Layer 3. See module docstring for the full
70
70
  * design + use-case mapping. Quick start:
@@ -98,37 +98,38 @@ const useThrottledFrameProcessor_1 = require("./useThrottledFrameProcessor");
98
98
  function useFrameStream(options, handler) {
99
99
  const sampleHz = Math.max(0.5, Math.min(10, options.sampleHz));
100
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)(() => {
101
+ // Default output dir: the lib's canonical capture dir resolved
102
+ // via `FileBridge.defaultCaptureDir()`. Same dir the lib uses
103
+ // for panorama JPEGs / keyframe JPEGs guaranteed writable on
104
+ // both platforms (iOS NSCachesDirectory + Android Context.cacheDir),
105
+ // created if missing. Resolved async on first mount; until
106
+ // resolution completes the worklet's `outputDir` is empty and
107
+ // the plugin call no-ops silently (a few frames missed at most;
108
+ // typical resolution time is <50ms).
109
+ //
110
+ // Hosts that want a specific path supply `options.outputDir`
111
+ // and skip the resolution entirely.
112
+ const [resolvedDefaultDir, setResolvedDefaultDir] = (0, react_1.useState)('');
113
+ (0, react_1.useEffect)(() => {
105
114
  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';
115
+ return;
116
+ let cancelled = false;
117
+ (0, files_1.getDefaultCaptureDir)()
118
+ .then((dir) => {
119
+ if (!cancelled)
120
+ setResolvedDefaultDir(dir);
121
+ })
122
+ .catch((err) => {
123
+ // eslint-disable-next-line no-console
124
+ console.warn('[useFrameStream] FileBridge.defaultCaptureDir() failed; ' +
125
+ 'samples will not fire until `options.outputDir` is supplied. ' +
126
+ String(err));
127
+ });
128
+ return () => {
129
+ cancelled = true;
130
+ };
121
131
  }, [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
+ const outputDir = options.outputDir ?? resolvedDefaultDir;
132
133
  // Stable JS-side handler reference for `runOnJS`. The hook re-
133
134
  // captures `handler` on every render but the ref keeps the
134
135
  // worklet closure pointing at the latest callback (avoid stale
@@ -152,18 +153,33 @@ function useFrameStream(options, handler) {
152
153
  // registry hasn't initialised yet (rare race on app start). We
153
154
  // retry every 16ms (one display frame) until success — matches
154
155
  // the pattern in `useFrameProcessorDriver`.
155
- const pluginRef = (0, react_1.useRef)(null);
156
+ //
157
+ // Use `useState` (not `useRef`) so the eventual non-null value
158
+ // triggers a re-render — the worklet closure below captures
159
+ // `plugin` by value at render time, so without state we'd
160
+ // capture `null` forever.
161
+ const [plugin, setPlugin] = (0, react_1.useState)(null);
156
162
  (0, react_1.useEffect)(() => {
157
163
  let cancelled = false;
158
164
  let timerId = null;
165
+ let attempts = 0;
159
166
  const tryAcquire = () => {
160
167
  if (cancelled)
161
168
  return;
169
+ attempts += 1;
162
170
  const p = react_native_vision_camera_1.VisionCameraProxy.initFrameProcessorPlugin('save_frame_as_jpeg', {});
163
171
  if (p != null) {
164
- pluginRef.current = p;
172
+ setPlugin(p);
165
173
  return;
166
174
  }
175
+ // After ~1s of failed retries, warn once — the plugin should
176
+ // be registered by then; persistent failure means the host's
177
+ // native bundle doesn't include `save_frame_as_jpeg`.
178
+ if (attempts === 60) {
179
+ // eslint-disable-next-line no-console
180
+ console.warn('[useFrameStream] save_frame_as_jpeg plugin not found after 1s of retries. ' +
181
+ 'Verify react-native-image-stitcher native module is installed in your host app.');
182
+ }
167
183
  timerId = setTimeout(tryAcquire, 16);
168
184
  };
169
185
  tryAcquire();
@@ -173,15 +189,14 @@ function useFrameStream(options, handler) {
173
189
  clearTimeout(timerId);
174
190
  };
175
191
  }, []);
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
192
  return (0, useThrottledFrameProcessor_1.useThrottledFrameProcessor)((frame) => {
182
193
  'worklet';
183
194
  if (plugin == null)
184
195
  return;
196
+ // Async outputDir resolution may not have completed yet on
197
+ // the first few frames after mount — bail until it does.
198
+ if (outputDir === '')
199
+ return;
185
200
  // Slot rotation: compute slot from frame timestamp. At
186
201
  // sampleHz=2 (500ms interval), the slot index changes every
187
202
  // ~1s, giving each slot ~2 samples before being overwritten.