react-native-image-stitcher 0.9.0 → 0.11.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 (35) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/android/build.gradle +10 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
  4. package/cpp/stitcher_worklet_registry.cpp +10 -0
  5. package/cpp/stitcher_worklet_registry.hpp +10 -0
  6. package/cpp/tests/CMakeLists.txt +98 -0
  7. package/cpp/tests/README.md +86 -0
  8. package/cpp/tests/pose_test.cpp +74 -0
  9. package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
  10. package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
  11. package/cpp/tests/stubs/jsi/jsi.h +33 -0
  12. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
  13. package/dist/camera/Camera.d.ts +30 -14
  14. package/dist/camera/Camera.js +18 -18
  15. package/dist/camera/useCapture.d.ts +1 -1
  16. package/dist/camera/useCapture.js +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +9 -1
  19. package/dist/stitching/incremental.d.ts +41 -0
  20. package/dist/stitching/useFrameProcessorDriver.d.ts +50 -95
  21. package/dist/stitching/useFrameProcessorDriver.js +76 -294
  22. package/dist/stitching/useFrameStream.js +52 -37
  23. package/dist/stitching/useStitcherWorklet.d.ts +185 -0
  24. package/dist/stitching/useStitcherWorklet.js +275 -0
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
  27. package/package.json +1 -1
  28. package/src/camera/Camera.tsx +48 -32
  29. package/src/camera/useCapture.ts +1 -1
  30. package/src/index.ts +13 -0
  31. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
  32. package/src/stitching/incremental.ts +42 -0
  33. package/src/stitching/useFrameProcessorDriver.ts +79 -320
  34. package/src/stitching/useFrameStream.ts +55 -39
  35. package/src/stitching/useStitcherWorklet.ts +390 -0
@@ -1863,12 +1863,27 @@ public final class IncrementalStitcher: NSObject {
1863
1863
  config: [String: Any],
1864
1864
  completion: @escaping ([String: Any]?, NSError?) -> Void
1865
1865
  ) {
1866
+ // v0.10.0 #15A — emit `validating` at the very top so JS sees
1867
+ // refine activity even when validation fails fast. Frames may
1868
+ // be empty here; report whatever the caller asked for.
1869
+ emitRefineProgress(
1870
+ stage: "validating",
1871
+ fraction: 0.05,
1872
+ frames: framePaths.count,
1873
+ errorMessage: nil
1874
+ )
1866
1875
  guard framePaths.count >= 2 else {
1876
+ let msg = "refinePanorama requires at least 2 framePaths (got \(framePaths.count))."
1877
+ emitRefineProgress(
1878
+ stage: "error",
1879
+ fraction: 1.0,
1880
+ frames: framePaths.count,
1881
+ errorMessage: msg
1882
+ )
1867
1883
  completion(nil, NSError(
1868
1884
  domain: "RNImageStitcherIncremental",
1869
1885
  code: 9101,
1870
- userInfo: [NSLocalizedDescriptionKey:
1871
- "refinePanorama requires at least 2 framePaths (got \(framePaths.count))."]
1886
+ userInfo: [NSLocalizedDescriptionKey: msg]
1872
1887
  ))
1873
1888
  return
1874
1889
  }
@@ -1876,11 +1891,17 @@ public final class IncrementalStitcher: NSObject {
1876
1891
  for p in framePaths {
1877
1892
  let cleaned = p.hasPrefix("file://") ? String(p.dropFirst(7)) : p
1878
1893
  if !fm.fileExists(atPath: cleaned) {
1894
+ let msg = "refinePanorama: keyframe missing on disk — \(cleaned)"
1895
+ emitRefineProgress(
1896
+ stage: "error",
1897
+ fraction: 1.0,
1898
+ frames: framePaths.count,
1899
+ errorMessage: msg
1900
+ )
1879
1901
  completion(nil, NSError(
1880
1902
  domain: "RNImageStitcherIncremental",
1881
1903
  code: 9102,
1882
- userInfo: [NSLocalizedDescriptionKey:
1883
- "refinePanorama: keyframe missing on disk — \(cleaned)"]
1904
+ userInfo: [NSLocalizedDescriptionKey: msg]
1884
1905
  ))
1885
1906
  return
1886
1907
  }
@@ -1912,7 +1933,18 @@ public final class IncrementalStitcher: NSObject {
1912
1933
  cleanedOutput,
1913
1934
  warper, blender, seam)
1914
1935
 
1915
- refineQueue.async {
1936
+ // v0.10.0 #15A — capture for the inner closure; `self` is
1937
+ // captured weakly so the progress emitter survives only as
1938
+ // long as the IncrementalStitcher itself does. An emitter
1939
+ // call on a torn instance is a no-op via `?.`.
1940
+ let frameCount = framePaths.count
1941
+ refineQueue.async { [weak self] in
1942
+ self?.emitRefineProgress(
1943
+ stage: "stitching",
1944
+ fraction: 0.1,
1945
+ frames: frameCount,
1946
+ errorMessage: nil
1947
+ )
1916
1948
  // C2-style: closure captures only value-typed locals
1917
1949
  // (paths, output path, config strings). No `self` access
1918
1950
  // is needed for the cv::Stitcher call — OpenCVStitcher is
@@ -1935,24 +1967,53 @@ public final class IncrementalStitcher: NSObject {
1935
1967
  // OpenCVStitcher hit one of its six guarded failure
1936
1968
  // returns; surface as a clean NSError.
1937
1969
  if r.width == 0 && r.height == 0 {
1970
+ let msg = "refinePanorama: stitcher returned sentinel — see preceding [BatchStitcher] log for cause."
1971
+ self?.emitRefineProgress(
1972
+ stage: "error",
1973
+ fraction: 1.0,
1974
+ frames: frameCount,
1975
+ errorMessage: msg
1976
+ )
1938
1977
  completion(nil, NSError(
1939
1978
  domain: "RNImageStitcherIncremental",
1940
1979
  code: 9107,
1941
- userInfo: [NSLocalizedDescriptionKey:
1942
- "refinePanorama: stitcher returned sentinel — see preceding [BatchStitcher] log for cause."]
1980
+ userInfo: [NSLocalizedDescriptionKey: msg]
1943
1981
  ))
1944
1982
  return
1945
1983
  }
1984
+ // Stitch succeeded — OpenCVStitcher writes the JPEG
1985
+ // internally before returning, so "writing" really
1986
+ // captures the final assembly + file I/O cost. Emit
1987
+ // here so JS can flip its label from "Stitching" to
1988
+ // "Writing" before the done event fires.
1989
+ self?.emitRefineProgress(
1990
+ stage: "writing",
1991
+ fraction: 0.9,
1992
+ frames: frameCount,
1993
+ errorMessage: nil
1994
+ )
1995
+ self?.emitRefineProgress(
1996
+ stage: "done",
1997
+ fraction: 1.0,
1998
+ frames: frameCount,
1999
+ errorMessage: nil
2000
+ )
1946
2001
  completion([
1947
2002
  "panoramaPath": r.outputPath,
1948
2003
  "width": Int(r.width),
1949
2004
  "height": Int(r.height),
1950
- "framesRequested": framePaths.count,
1951
- "framesIncluded": framePaths.count,
2005
+ "framesRequested": frameCount,
2006
+ "framesIncluded": frameCount,
1952
2007
  "framesDropped": 0,
1953
2008
  "finalConfidenceThresh": -1.0,
1954
2009
  ], nil)
1955
2010
  } catch let err as NSError {
2011
+ self?.emitRefineProgress(
2012
+ stage: "error",
2013
+ fraction: 1.0,
2014
+ frames: frameCount,
2015
+ errorMessage: err.localizedDescription
2016
+ )
1956
2017
  completion(nil, err)
1957
2018
  }
1958
2019
  }
@@ -2092,6 +2153,74 @@ public final class IncrementalStitcher: NSObject {
2092
2153
  )
2093
2154
  }
2094
2155
 
2156
+ /// v0.10.0 #15A — emit a refine-pipeline phase update on the same
2157
+ /// `IncrementalStateUpdate` channel that carries `isRefining` /
2158
+ /// `refinedPanoramaPath`. Five `stage` values fire across the
2159
+ /// lifetime of one `refinePanorama` call:
2160
+ ///
2161
+ /// - `validating` (fraction 0.05) — synchronous input checks
2162
+ /// - `stitching` (fraction 0.10) — start of the OpenCV stitch
2163
+ /// - `writing` (fraction 0.90) — stitch returned, JPEG written
2164
+ /// - `done` (fraction 1.00) — completion handler invoked
2165
+ /// - `error` (fraction 1.00) — failure path (`errorMessage`
2166
+ /// is non-nil)
2167
+ ///
2168
+ /// `fraction` is intentionally coarse: OpenCV's `Stitcher` doesn't
2169
+ /// expose stage-by-stage callbacks, so the 0.10 → 0.90 jump is a
2170
+ /// single opaque step. JS uses the `stage` string for the UI
2171
+ /// label and `fraction` purely for spinner progress.
2172
+ ///
2173
+ /// Reuses the existing channel (rather than introducing a new
2174
+ /// device-event name) so the JS subscriber doesn't need to wire
2175
+ /// a second listener. The payload carries the same skeleton
2176
+ /// `emitRefinementState` emits (lastState fields preserved) so
2177
+ /// `isRefining` / `refinedPanoramaPath` sticky-merge logic on the
2178
+ /// JS side keeps working untouched.
2179
+ private func emitRefineProgress(
2180
+ stage: String,
2181
+ fraction: Double,
2182
+ frames: Int?,
2183
+ errorMessage: String?
2184
+ ) {
2185
+ // Disk-trail breadcrumb — every refine emit lands here so a
2186
+ // future regression can be diagnosed by pulling the bridge's
2187
+ // debug file without needing live Console.app access.
2188
+ IncrementalStitcher.fileLog(
2189
+ "[refine.progress] stage=\(stage) frac=\(fraction) frames=\(frames ?? -1) hasError=\(errorMessage != nil)"
2190
+ )
2191
+ stateLock.lock()
2192
+ let prev = self.lastState
2193
+ stateLock.unlock()
2194
+ let state = IncrementalStateObject(
2195
+ panoramaPath: prev?.panoramaPath,
2196
+ width: prev?.width ?? 0,
2197
+ height: prev?.height ?? 0,
2198
+ acceptedCount: prev?.acceptedCount ?? 0,
2199
+ outcome: prev?.outcome ?? .acceptedHigh,
2200
+ confidence: prev?.confidence ?? 1.0,
2201
+ overlapPercent: prev?.overlapPercent ?? -1.0,
2202
+ processingMs: 0,
2203
+ isLandscape: prev?.isLandscape ?? false,
2204
+ paintedExtent: prev?.paintedExtent ?? 0,
2205
+ panExtent: prev?.panExtent ?? 0,
2206
+ keyframeMax: prev?.keyframeMax ?? 0
2207
+ )
2208
+ var dict = state.asDictionary()
2209
+ dict["refineStage"] = stage
2210
+ dict["refineProgress"] = fraction
2211
+ if let f = frames {
2212
+ dict["refineFrames"] = f
2213
+ }
2214
+ if let e = errorMessage {
2215
+ dict["refineError"] = e
2216
+ }
2217
+ NotificationCenter.default.post(
2218
+ name: .retailensIncrementalStateUpdate,
2219
+ object: nil,
2220
+ userInfo: dict
2221
+ )
2222
+ }
2223
+
2095
2224
  /// Cancel an in-progress capture without producing output.
2096
2225
  /// Same V12.1 synchronous-stop pattern as finalize.
2097
2226
  @objc public func cancel() {
@@ -33,6 +33,18 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
33
33
 
34
34
  public override init() {
35
35
  super.init()
36
+ // Under RN bridgeless interop the bridge's init() can be
37
+ // invoked twice on the same instance (observed via identical
38
+ // instance pointers firing the observer selector twice per
39
+ // notification). Defensively remove any prior registration
40
+ // for this notification name before adding one, so the
41
+ // observer can only fire once per post regardless of how
42
+ // many times init runs.
43
+ NotificationCenter.default.removeObserver(
44
+ self,
45
+ name: .retailensIncrementalStateUpdate,
46
+ object: nil
47
+ )
36
48
  // Subscribe once at construction. The handler self-checks
37
49
  // `hasListeners` before forwarding, so we don't have to
38
50
  // unsubscribe / resubscribe on every JS listener attach/detach.
@@ -58,9 +70,6 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
58
70
  return [Self.stateUpdateEvent]
59
71
  }
60
72
 
61
- // (startObserving / stopObserving moved next to handleStateUpdate
62
- // for the PiP investigation; remove this comment after.)
63
-
64
73
  // MARK: - Module methods
65
74
 
66
75
  /// `options` (all optional, sensible defaults documented in
@@ -477,26 +486,53 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
477
486
 
478
487
  @objc private func handleStateUpdate(_ notification: Notification) {
479
488
  let hasPath = (notification.userInfo?["panoramaPath"] != nil)
480
- if hasPath {
489
+ let refineStage = notification.userInfo?["refineStage"] as? String
490
+ if hasPath || refineStage != nil {
481
491
  IncrementalStitcher.fileLog(
482
- "bridge handleStateUpdate hasListeners=\(hasListeners) hasPath=\(hasPath) thread=\(Thread.isMainThread ? "main" : "bg")"
492
+ "bridge handleStateUpdate hasListeners=\(hasListeners) hasPath=\(hasPath) refineStage=\(refineStage ?? "nil") thread=\(Thread.isMainThread ? "main" : "bg")"
483
493
  )
484
494
  }
485
- guard hasListeners else { return }
495
+ guard hasListeners else {
496
+ if let stage = refineStage {
497
+ IncrementalStitcher.fileLog(
498
+ "bridge handleStateUpdate DROPPED refineStage=\(stage) — hasListeners=false"
499
+ )
500
+ }
501
+ return
502
+ }
486
503
  guard let userInfo = notification.userInfo else { return }
487
- // FIX: RCTEventEmitter.sendEvent is documented to be called
488
- // from any thread, but in practice events from background
489
- // threads can be dropped silently if the bridge is in
490
- // certain states. Dispatch to main queue to guarantee
491
- // delivery. See e.g. RN issues #19518, #28250.
504
+ // We deliver via `bridge.enqueueJSCall("RCTDeviceEventEmitter", "emit", ...)`
505
+ // rather than `RCTEventEmitter.sendEvent(...)` because under RN
506
+ // bridgeless interop `sendEvent` silently no-ops for some
507
+ // event-body shapes even when `_bridge` is non-nil and
508
+ // `_listenerCount > 0` (confirmed via os_log instrumentation
509
+ // during v0.10.0 PR B development — refine events with
510
+ // refineStage/refineProgress/refineFrames were not reaching
511
+ // any JS subscriber while live state events with a smaller
512
+ // body shape on the same channel were). enqueueJSCall is
513
+ // the underlying mechanism sendEvent uses in Paper mode, so
514
+ // it is strictly at least as well-supported.
492
515
  DispatchQueue.main.async { [weak self] in
493
516
  guard let self = self else { return }
494
- if hasPath {
517
+ if hasPath || refineStage != nil {
495
518
  IncrementalStitcher.fileLog(
496
- "bridge sendEvent (main queue) body.panoramaPath=\(userInfo["panoramaPath"] ?? "MISSING")"
519
+ "bridge enqueueJSCall (main queue) body.panoramaPath=\(userInfo["panoramaPath"] ?? "MISSING") refineStage=\(refineStage ?? "nil")"
497
520
  )
498
521
  }
499
- self.sendEvent(withName: Self.stateUpdateEvent, body: userInfo)
522
+ guard let bridge = self.bridge else {
523
+ if hasPath || refineStage != nil {
524
+ IncrementalStitcher.fileLog(
525
+ "bridge enqueueJSCall DROPPED — self.bridge is nil"
526
+ )
527
+ }
528
+ return
529
+ }
530
+ bridge.enqueueJSCall(
531
+ "RCTDeviceEventEmitter",
532
+ method: "emit",
533
+ args: [Self.stateUpdateEvent, userInfo],
534
+ completion: nil
535
+ )
500
536
  }
501
537
  }
502
538
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -323,26 +323,42 @@ export interface CameraProps {
323
323
  * }
324
324
  * ```
325
325
  *
326
- * ## Non-AR mode tradeoff (HONEST)
326
+ * ## Non-AR mode composition (v0.11.0+)
327
327
  *
328
328
  * vision-camera's `<Camera>` accepts ONLY ONE frame processor.
329
329
  * The lib's internal `useFrameProcessorDriver` produces the
330
330
  * processor that drives first-party panorama stitching in non-AR
331
- * mode. If you supply your own via this prop, **the lib's
332
- * first-party stitching is replaced**panorama capture in
333
- * non-AR mode will not produce stitched output until you remove
334
- * the prop or fork the SDK to compose both worklets manually.
331
+ * mode. If you supply your own via this prop, the lib's
332
+ * default processor is REPLACEDbut as of v0.11.0 you can
333
+ * COMPOSE first-party stitching back into your worklet body
334
+ * using `useStitcherWorklet`:
335
335
  *
336
- * For the common case (host wants worklet + lib wants stitching
337
- * concurrently), prefer AR mode: the AR-mode path natively fans
338
- * out to both the lib's first-party stitching AND every
339
- * registered host worklet on every frame, with per-worklet
340
- * failure isolation.
336
+ * ```tsx
337
+ * import {
338
+ * Camera, useFrameProcessor, useStitcherWorklet,
339
+ * type StitcherFrame,
340
+ * } from 'react-native-image-stitcher';
341
341
  *
342
- * Composition for non-AR mode (lib stitching + host worklet on
343
- * the same vc processor) is tracked as a v0.9+ follow-up;
344
- * needs the lib's first-party logic exposed as a vc Frame
345
- * Processor plugin the host's worklet can call.
342
+ * function MyScreen() {
343
+ * const stitcher = useStitcherWorklet();
344
+ * const fp = useFrameProcessor((frame: StitcherFrame) => {
345
+ * 'worklet';
346
+ * hostPreLogic(frame);
347
+ * stitcher.call(frame); // ← first-party stitching
348
+ * hostPostLogic(frame);
349
+ * }, [stitcher.call]);
350
+ * return <Camera frameProcessor={fp} ... />;
351
+ * }
352
+ * ```
353
+ *
354
+ * Hosts that DON'T call `useStitcherWorklet` from their worklet
355
+ * body replace first-party stitching for non-AR captures (a
356
+ * one-shot console.info documents this when the prop is first
357
+ * supplied). AR mode is unaffected either way — the AR-mode
358
+ * dispatch path (v0.8.0 Phase 4b.i / 4b.iii) natively fans out
359
+ * to both the lib's first-party stitching AND every registered
360
+ * host worklet on every frame, with per-worklet failure
361
+ * isolation.
346
362
  *
347
363
  * ## AR mode behaviour
348
364
  *
@@ -841,24 +857,23 @@ export function Camera(props: CameraProps): React.JSX.Element {
841
857
  // eslint-disable-next-line react-hooks/exhaustive-deps
842
858
  useEffect(() => () => { fpDriver.stop(); }, []);
843
859
 
844
- // v0.8.0 Phase 5 — frameProcessor prop semantics:
860
+ // v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
845
861
  //
846
- // - Host supplied? → use host's processor; lib's first-party
847
- // stitching is DISABLED in non-AR mode (vc accepts only one
848
- // processor). One-shot console.info documents the tradeoff
849
- // so the host isn't surprised by "panorama capture stopped
850
- // producing output" in non-AR mode. AR-mode capture is
851
- // unaffected the AR-session dispatch path fans out to BOTH
852
- // first-party and host worklets independently.
862
+ // - Host supplied? → use host's processor. The host's worklet
863
+ // body controls whether first-party stitching also fires:
864
+ // call `stitcher.call(frame)` (from `useStitcherWorklet`)
865
+ // inside the body to compose; omit to replace. One-shot
866
+ // console.info documents the choice so the host can spot a
867
+ // missing `useStitcherWorklet` call before they go hunting
868
+ // for "why is non-AR panorama capture not producing output".
869
+ // AR-mode capture is unaffected either way — the AR-session
870
+ // dispatch path fans out to BOTH first-party stitching AND
871
+ // every host worklet independently.
853
872
  //
854
873
  // - No host processor? → use `fpDriver.frameProcessor` which is
855
874
  // the lib's internal worklet driving first-party stitching
856
875
  // via `useFrameProcessorDriver`. Default behaviour for the
857
876
  // common "I just want panorama capture" case.
858
- //
859
- // The pre-v0.8.0 behaviour (host's prop silently ignored with
860
- // a warning) is gone — Phase 5 plumbs the prop through. The
861
- // tradeoff is honestly documented in the CameraProps docstring.
862
877
  const hostFrameProcessorAcceptedWarnedRef = useRef(false);
863
878
  if (
864
879
  hostFrameProcessor != null
@@ -868,12 +883,13 @@ export function Camera(props: CameraProps): React.JSX.Element {
868
883
  // eslint-disable-next-line no-console
869
884
  console.info(
870
885
  '[react-native-image-stitcher] Host frameProcessor supplied — '
871
- + 'non-AR mode will run YOUR worklet instead of the lib\'s '
872
- + 'first-party stitching plugin (vc accepts only one frame '
873
- + 'processor). Non-AR panorama capture will not produce '
874
- + 'stitched output until this prop is removed. AR-mode '
875
- + 'capture is unaffected (AR-session dispatch fans out to '
876
- + 'both first-party and host worklets independently).',
886
+ + 'non-AR mode will run YOUR composed worklet. If you want '
887
+ + 'first-party panorama stitching alongside your own logic, '
888
+ + 'call `useStitcherWorklet()` and invoke `stitcher.call(frame)` '
889
+ + 'from your worklet body (see `<Camera>` `frameProcessor` '
890
+ + 'JSDoc for the composition pattern). AR-mode capture is '
891
+ + 'unaffected (AR-session dispatch fans out to both '
892
+ + 'first-party and host worklets independently).',
877
893
  );
878
894
  }
879
895
  // The Frame Processor worklet bound to vision-camera's Camera.
@@ -18,7 +18,7 @@
18
18
  * - This hook does NOT persist captures. Host apps hand the
19
19
  * returned CaptureResult to their own storage layer (WatermelonDB
20
20
  * insert, Redux dispatch, whatever).
21
- * - Video recording lives in useVideoCapture (TODO).
21
+ * - Video recording lives in useVideoCapture.
22
22
  *
23
23
  * The public API is designed to be minimal and replaceable: host apps
24
24
  * that prefer the raw vision-camera API can opt out of this hook and
package/src/index.ts CHANGED
@@ -226,6 +226,19 @@ export type {
226
226
  FrameProcessorDriverHandle,
227
227
  } from './stitching/useFrameProcessorDriver';
228
228
 
229
+ // v0.11.0 — composable first-party stitching as a worklet function.
230
+ // Hosts that want to COMPOSE their own per-frame logic with the
231
+ // lib's stitching (instead of REPLACING it via the <Camera>
232
+ // `frameProcessor` prop) call this hook + invoke `stitcher.call`
233
+ // inside their own `useFrameProcessor` body. See
234
+ // `docs/host-app-integration.md` § Tier 3 for the full pattern.
235
+ export { useStitcherWorklet } from './stitching/useStitcherWorklet';
236
+ export type {
237
+ UseStitcherWorkletOptions,
238
+ StitcherWorkletHandle,
239
+ StitcherWorkletInput,
240
+ } from './stitching/useStitcherWorklet';
241
+
229
242
  // ── Batch stitching ───────────────────────────────────────────────────
230
243
  // Feed a video file straight to OpenCV's cv::Stitcher, bypassing the
231
244
  // incremental pipeline. Useful when you have content captured