react-native-image-stitcher 0.5.1 → 0.7.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 (28) hide show
  1. package/CHANGELOG.md +199 -1
  2. package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +2 -2
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +2 -30
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +90 -368
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +6 -3
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +17 -30
  7. package/dist/camera/Camera.d.ts +11 -27
  8. package/dist/camera/Camera.js +46 -78
  9. package/dist/index.d.ts +2 -3
  10. package/dist/index.js +10 -6
  11. package/dist/stitching/incremental.d.ts +79 -11
  12. package/dist/stitching/useFrameProcessorDriver.d.ts +7 -6
  13. package/dist/stitching/useFrameProcessorDriver.js +12 -11
  14. package/dist/stitching/useKeyframeStream.d.ts +69 -0
  15. package/dist/stitching/useKeyframeStream.js +120 -0
  16. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +48 -208
  17. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +0 -8
  18. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +6 -126
  19. package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +6 -6
  20. package/package.json +1 -1
  21. package/src/camera/Camera.tsx +57 -106
  22. package/src/index.ts +9 -9
  23. package/src/stitching/incremental.ts +84 -11
  24. package/src/stitching/useFrameProcessorDriver.ts +12 -11
  25. package/src/stitching/useKeyframeStream.ts +127 -0
  26. package/dist/stitching/useIncrementalJSDriver.d.ts +0 -74
  27. package/dist/stitching/useIncrementalJSDriver.js +0 -220
  28. package/src/stitching/useIncrementalJSDriver.ts +0 -297
@@ -20,7 +20,6 @@
20
20
  import Foundation
21
21
  import React
22
22
  import os.log
23
- import ImageIO // CGImageSource + kCGImagePropertyOrientation for EXIF read in processFrameAtPath
24
23
 
25
24
  @objc(IncrementalStitcherBridge)
26
25
  public final class IncrementalStitcherBridge: RCTEventEmitter {
@@ -75,10 +74,12 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
75
74
  /// Resolves with `{ ok: true }`. Rejects when `frameSourceMode`
76
75
  /// (options dict) is 'arSession' (the default) AND the AR session
77
76
  /// isn't running — that path needs ARKit to deliver frames.
78
- /// When `frameSourceMode` is 'jsDriver' the AR-session check is
79
- /// skipped and the engine expects JS to feed frames via
80
- /// `processFrameAtPath` (used by iOS non-AR captures since
81
- /// 2026-05-18 / Issue #2 regression fix).
77
+ /// When `frameSourceMode` is 'frameProcessor' the AR-session check
78
+ /// is skipped and the engine expects the vision-camera Frame
79
+ /// Processor plugin (`CvFlowGateFrameProcessor`) to feed frames
80
+ /// via `consumeFrameFromPlugin`. The pre-v0.6 'jsDriver' mode
81
+ /// (push frames in from JS via `processFrameAtPath`) has been
82
+ /// removed.
82
83
  @objc(start:resolver:rejecter:)
83
84
  public func start(
84
85
  options: NSDictionary,
@@ -251,127 +252,6 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
251
252
  resolver(["ok": true])
252
253
  }
253
254
 
254
- /// 2026-05-18 (Issue #2 v2) — JS-driven frame ingestion for iOS
255
- /// non-AR mode. Mirrors Android's `processFrameAtPath` exactly:
256
- /// the JPEG at `path` is already saved on disk by vision-camera
257
- /// in its native EXIF-correct orientation. We DO NOT decode the
258
- /// image here. Instead:
259
- ///
260
- /// - Build a synthetic `RNSARFramePose` from the
261
- /// JS-supplied quaternion + intrinsics (no translation;
262
- /// non-AR captures don't have it).
263
- /// - Hand the path + pose to
264
- /// `IncrementalStitcher.addBatchKeyframePath`, which
265
- /// evaluates the shared-C++ KeyframeGate and (if accepted)
266
- /// records the path in the finalize-time keyframe list +
267
- /// emits the same state event the AR-delegate path emits.
268
- /// - `cv::imread` at finalize handles EXIF orientation
269
- /// natively, so the output panorama reads upright with no
270
- /// iOS-specific orientation handling needed in this bridge.
271
- ///
272
- /// History: Issue #2 v1 (commit 0e40f17) tried to decode the
273
- /// JPEG into a CVPixelBuffer and reuse the existing AR
274
- /// `consumeFrame(pixelBuffer:pose:)` path. That introduced two
275
- /// orientation bugs (CGContext Y-flip + UIImage.size vs
276
- /// cgImage.width dim swap) → upside-down output AND canvas-
277
- /// dimension overflow → OOM crashes (user-reported 2026-05-18).
278
- /// Architecturally Android never decoded the image either, so
279
- /// the right fix was to mirror that.
280
- ///
281
- /// `options` keys:
282
- /// - path (NSString, required) — local file path (no file://)
283
- /// - qx, qy, qz, qw (Double, required) — quaternion, JS-side
284
- /// gyro-integrated
285
- /// - fx, fy, cx, cy (Double, required) — intrinsics in sensor px
286
- /// - imageWidth, imageHeight (Int, required)
287
- /// - trackingPoor (Bool, optional, default false)
288
- /// - timestampMs (Double, optional, default = now)
289
- ///
290
- /// Only batch-keyframe captures are supported on this path right
291
- /// now — other engines (hybrid / firstwins) need real pixel data
292
- /// during the live phase, which isn't trivially derivable from a
293
- /// JPEG path. Reject with `E_NOT_BATCH_KEYFRAME` so the JS host
294
- /// can fall back to the legacy stitchVideo path if needed.
295
- @objc(processFrameAtPath:resolver:rejecter:)
296
- public func processFrameAtPath(
297
- options: NSDictionary,
298
- resolver: @escaping RCTPromiseResolveBlock,
299
- rejecter: @escaping RCTPromiseRejectBlock
300
- ) {
301
- guard let pathRaw = options["path"] as? String, !pathRaw.isEmpty else {
302
- rejecter("E_NO_PATH", "processFrameAtPath: missing 'path'", nil)
303
- return
304
- }
305
- // Strip optional file:// prefix — JS callers sometimes send
306
- // file URIs, native APIs want filesystem paths.
307
- let cleanPath = pathRaw.hasPrefix("file://")
308
- ? String(pathRaw.dropFirst("file://".count))
309
- : pathRaw
310
-
311
- let engine = IncrementalStitcher.shared
312
- guard engine.isBatchKeyframeMode else {
313
- rejecter("E_NOT_BATCH_KEYFRAME",
314
- "processFrameAtPath only supports batch-keyframe "
315
- + "engine mode on iOS. Configure "
316
- + "incrementalEngine='batch-keyframe' in start() "
317
- + "options, or fall back to the stitchVideo path.",
318
- nil)
319
- return
320
- }
321
-
322
- let qx = (options["qx"] as? Double) ?? 0
323
- let qy = (options["qy"] as? Double) ?? 0
324
- let qz = (options["qz"] as? Double) ?? 0
325
- let qw = (options["qw"] as? Double) ?? 1 // identity quat default
326
- let fx = (options["fx"] as? Double) ?? 1000.0
327
- let fy = (options["fy"] as? Double) ?? 1000.0
328
- let cx = (options["cx"] as? Double) ?? 540.0
329
- let cy = (options["cy"] as? Double) ?? 960.0
330
- let imageWidth = (options["imageWidth"] as? Int) ?? 1080
331
- let imageHeight = (options["imageHeight"] as? Int) ?? 1920
332
- let trackingPoor = (options["trackingPoor"] as? Bool) ?? false
333
- let timestampMs = (options["timestampMs"] as? Double)
334
- ?? (Date().timeIntervalSince1970 * 1000.0)
335
- let trackingState: RNSARTrackingState =
336
- trackingPoor ? .limited : .tracking
337
-
338
- let pose = RNSARFramePose(
339
- tx: 0, ty: 0, tz: 0, // no translation in non-AR
340
- qx: qx, qy: qy, qz: qz, qw: qw,
341
- fx: fx, fy: fy, cx: cx, cy: cy,
342
- imageWidth: imageWidth, imageHeight: imageHeight,
343
- timestampMs: timestampMs,
344
- trackingState: trackingState
345
- )
346
-
347
- // 2026-05-18 (Iss #1 diag) — read EXIF Orientation tag from the
348
- // keyframe JPEG before handing it to the engine. vision-camera
349
- // writes a JPEG with an EXIF tag matching the physical capture
350
- // orientation (1=no rotation, 3=180°, 6=90°CW, 8=90°CCW). The
351
- // bake-rotation table in cpp/stitcher.cpp assumes the post-imread
352
- // Mat is in user-view orientation (post-EXIF apply). If the EXIF
353
- // tag isn't what we expect for a given physical orientation, the
354
- // input Mat to cv::Stitcher will be a different shape than the AR
355
- // path produces (AR keyframes hardcode EXIF=6, commit 7b828f1) —
356
- // which would explain why iOS non-AR landscape captures stitch
357
- // but bake the wrong way. CGImageSource is cheap (metadata-only;
358
- // no decode).
359
- var exifOrientation: Int = -1
360
- if let src = CGImageSourceCreateWithURL(
361
- URL(fileURLWithPath: cleanPath) as CFURL, nil
362
- ),
363
- let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
364
- let o = props[kCGImagePropertyOrientation] as? Int {
365
- exifOrientation = o
366
- }
367
- os_log(.fault, log: OSLog(subsystem: "com.tiger.retailens",
368
- category: "stitcher.diag"),
369
- "[V16-batch-keyframe.js] processFrameAtPath EXIF=%d imageW=%d imageH=%d path=%{public}@",
370
- Int32(exifOrientation), Int32(imageWidth), Int32(imageHeight), cleanPath)
371
-
372
- let accepted = engine.addBatchKeyframePath(path: cleanPath, pose: pose)
373
- resolver(["ok": true, "accepted": accepted])
374
- }
375
255
 
376
256
  /// 2026-05-18 (Iss 3) — bridge for `cleanupKeyframes`. See the
377
257
  /// Swift method's docstring for behaviour. Options dict keys:
@@ -140,13 +140,13 @@ static NSInteger kg_argInt(NSDictionary* args, NSString* key, NSInteger defaultV
140
140
  // Pose from worklet args. Defaults are safe non-AR values:
141
141
  // * tx/ty/tz = 0 (no translation in non-AR; gyro only gives rot)
142
142
  // * qw = 1 (identity quaternion if JS hasn't supplied rotation)
143
- // * fx/fy/cx/cy = 0 → JS-driver caller MUST supply these (the
144
- // engine derives FoV from intrinsics; 0 would yield NaN FoV).
145
- // We default the principal point to image centre as a safer
146
- // fallback if only fx/fy are missing.
143
+ // * fx/fy/cx/cy = 0 → the Frame Processor worklet caller MUST
144
+ // supply these (the engine derives FoV from intrinsics; 0 would
145
+ // yield NaN FoV). We default the principal point to image
146
+ // centre as a safer fallback if only fx/fy are missing.
147
147
  // * trackingStateRaw = 2 → `.tracking` (non-AR captures don't
148
- // have a real tracking-quality signal; engine's `trackingPoor`
149
- // path stays inactive, matching legacy `useIncrementalJSDriver`).
148
+ // have a real tracking-quality signal; reporting `.tracking`
149
+ // keeps the engine's `trackingPoor` path inactive).
150
150
  double tx = kg_argDouble(arguments, @"tx", 0.0);
151
151
  double ty = kg_argDouble(arguments, @"ty", 0.0);
152
152
  double tz = kg_argDouble(arguments, @"tz", 0.0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",
@@ -89,7 +89,6 @@ import {
89
89
  subscribeIncrementalState,
90
90
  type IncrementalState,
91
91
  } from '../stitching/incremental';
92
- import { useIncrementalJSDriver } from '../stitching/useIncrementalJSDriver';
93
92
  import { useFrameProcessorDriver } from '../stitching/useFrameProcessorDriver';
94
93
  import { useIncrementalStitcher } from '../stitching/useIncrementalStitcher';
95
94
  import { useIMUTranslationGate } from '../sensors/useIMUTranslationGate';
@@ -303,37 +302,20 @@ export interface CameraProps {
303
302
  * Introduced for F8 (FrameProcessor port) — see
304
303
  * `docs/f8-frame-processor-plan.md`.
305
304
  *
306
- * As of v0.5 (F8.3) this prop is **deprecated for the standard
307
- * non-AR capture flow**: the SDK now installs its own frame
308
- * processor via `useFrameProcessorDriver` that pipes pixel
309
- * buffers into the incremental stitcher with synthesised pose.
310
- * Setting this prop in the default mode will be IGNORED with a
311
- * one-time console.warn — supplying your own worklet would race
312
- * with the SDK's pixel-buffer feed.
305
+ * The SDK installs its own frame processor via
306
+ * `useFrameProcessorDriver`. Setting this prop is ignored with
307
+ * a one-time `console.warn` supplying a host worklet would
308
+ * race with the SDK's pixel-buffer feed. Either remove the prop
309
+ * or fork the SDK if you genuinely need a custom worklet.
313
310
  *
314
- * Three coexistence rules:
315
- * * Default (modern non-AR): SDK owns the worklet, this prop
316
- * is ignored.
317
- * * `legacyDriver={true}`: SDK uses the old `useIncrementalJSDriver`
318
- * (takeSnapshot path). Honoured for diagnostics or as an
319
- * escape hatch.
320
- * * AR mode: vision-camera Camera isn't mounted, this prop is
321
- * irrelevant.
322
- */
323
- frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
324
-
325
- /**
326
- * Opt back into the legacy `useIncrementalJSDriver` for non-AR
327
- * captures (the v0.4 path: `takeSnapshot` → JPEG → cache file →
328
- * `IncrementalStitcher.processFrameAtPath`).
311
+ * AR mode is irrelevant: vision-camera's Camera isn't mounted.
329
312
  *
330
- * Default `false` (use the new `useFrameProcessorDriver`, which
331
- * runs the gate on the camera producer thread at native frame
332
- * rate via a vision-camera Frame Processor plugin). The legacy
333
- * path will be removed in v0.6 — set this only if you hit a
334
- * specific issue with the new driver and need to ship a fix.
313
+ * (v0.5 had a `legacyDriver` escape hatch that routed back to
314
+ * `useIncrementalJSDriver`. That hook + prop were removed in
315
+ * v0.6 per the deprecation timeline announced in the v0.5.0
316
+ * CHANGELOG.)
335
317
  */
336
- legacyDriver?: boolean;
318
+ frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
337
319
  }
338
320
 
339
321
 
@@ -609,7 +591,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
609
591
  onFramesDropped,
610
592
  onError,
611
593
  frameProcessor: hostFrameProcessor,
612
- legacyDriver = false,
613
594
  engine = 'batch-keyframe',
614
595
  } = props;
615
596
 
@@ -790,63 +771,49 @@ export function Camera(props: CameraProps): React.JSX.Element {
790
771
  },
791
772
  });
792
773
 
793
- // JS-driver for non-AR captures (iOS + Android). In AR mode the
794
- // engine consumes frames from the ARSession stream natively, so this
795
- // hook stays idle.
774
+ // Frame Processor driver for non-AR captures (iOS + Android).
775
+ // In AR mode the engine consumes frames from the ARSession stream
776
+ // natively, so this hook stays idle.
796
777
  //
797
778
  // IMPORTANT: start()/stop() are called imperatively from the hold
798
779
  // handlers below — NOT from a useEffect driven by statusPhase. The
799
780
  // hook returns a fresh object identity on every render, and during
800
781
  // a recording the engine emits IncrementalStateUpdate events that
801
- // cause re-renders multiple times per second. An effect with
802
- // `jsDriver` in its deps would teardown + restart the driver on
803
- // every event, resetting the gyro accumulator (yaw/pitch) to zero
804
- // each cycle and nulling the cameraRef during the brief gap. The
805
- // user-visible symptom was "only the first keyframe is accepted,
806
- // every subsequent snapshot sees pose=(0,0) and is rejected as a
807
- // duplicate of the first". Matching AuditCaptureScreen's proven
808
- // imperative pattern (start on hold-start, stop on hold-end) avoids
809
- // the re-render churn entirely.
810
- const jsDriver = useIncrementalJSDriver();
811
- // F8.3 — vision-camera Frame Processor variant. Always
812
- // instantiated so we don't have conditional hook calls; only one
813
- // of the two drivers actually .start()s per capture. Stop() on
814
- // an idle driver is a no-op.
782
+ // cause re-renders multiple times per second. An effect with the
783
+ // driver in its deps would teardown + restart on every event,
784
+ // resetting the gyro accumulator (yaw/pitch) to zero each cycle.
785
+ // User-visible symptom: "only the first keyframe is accepted, every
786
+ // subsequent ingest sees pose=(0,0) and is rejected as a duplicate".
787
+ // The imperative pattern (start on hold-start, stop on hold-end)
788
+ // avoids the re-render churn entirely.
815
789
  const fpDriver = useFrameProcessorDriver();
816
- // Safety: ensure both drivers are stopped if the component unmounts
817
- // mid-recording. Empty deps so this only fires on unmount.
790
+ // Safety: stop the driver if the component unmounts mid-recording.
818
791
  // eslint-disable-next-line react-hooks/exhaustive-deps
819
- useEffect(() => () => { jsDriver.stop(); fpDriver.stop(); }, []);
820
-
821
- // F8.3 — one-shot deprecation warning when the host supplies their
822
- // own `frameProcessor` while running in the default (Frame
823
- // Processor driver) mode. Two worklets racing on the same
824
- // producer thread would corrupt the engine's workQueue ordering;
825
- // the SDK's own worklet wins and the host's is ignored. Hosts
826
- // that *need* a custom worklet must opt into `legacyDriver={true}`
827
- // (which switches off the SDK's worklet entirely).
792
+ useEffect(() => () => { fpDriver.stop(); }, []);
793
+
794
+ // One-shot deprecation warning when the host supplies their own
795
+ // `frameProcessor` prop. Two worklets racing on the same
796
+ // producer thread would corrupt the engine's workQueue ordering,
797
+ // so the SDK's own worklet wins and the host's is silently
798
+ // ignored. (v0.5 had a `legacyDriver` opt-out for hosts that
799
+ // wanted to route around the SDK driver; that was removed in
800
+ // v0.6 along with `useIncrementalJSDriver`.)
828
801
  const hostFrameProcessorIgnoredWarnedRef = useRef(false);
829
802
  if (
830
803
  hostFrameProcessor != null
831
- && !legacyDriver
832
804
  && !hostFrameProcessorIgnoredWarnedRef.current
833
805
  ) {
834
806
  hostFrameProcessorIgnoredWarnedRef.current = true;
835
807
  // eslint-disable-next-line no-console
836
808
  console.warn(
837
809
  '[react-native-image-stitcher] The `frameProcessor` prop on '
838
- + '<Camera> is ignored when the default driver is active '
839
- + '(legacyDriver=false). Either remove the prop or set '
840
- + 'legacyDriver={true} to opt into the legacy path.',
810
+ + '<Camera> is ignored the SDK installs its own worklet '
811
+ + 'via useFrameProcessorDriver. Remove the prop, or fork '
812
+ + 'the SDK if you genuinely need a custom worklet.',
841
813
  );
842
814
  }
843
- // The Frame Processor worklet actually bound to vision-camera's
844
- // Camera. Resolution order:
845
- // 1. Legacy mode: honor the host's prop (or null).
846
- // 2. Modern mode: SDK driver's worklet, regardless of host's prop.
847
- const effectiveFrameProcessor = legacyDriver
848
- ? (hostFrameProcessor ?? null)
849
- : fpDriver.frameProcessor;
815
+ // The Frame Processor worklet bound to vision-camera's Camera.
816
+ const effectiveFrameProcessor = fpDriver.frameProcessor;
850
817
 
851
818
  // ── Subscribe to engine state for live keyframe thumbs ──────────
852
819
  useEffect(() => {
@@ -903,17 +870,17 @@ export function Camera(props: CameraProps): React.JSX.Element {
903
870
  const accepted = incrementalState?.acceptedCount ?? 0;
904
871
  if (accepted > lastAcceptedCountRef.current) {
905
872
  lastAcceptedCountRef.current = accepted;
906
- // F8.3 review-of-review (M3 revert): originally gated this to
907
- // `legacyDriver` because the Frame Processor driver doesn't
908
- // consult `imuGate` for its own pose synthesis. That ignored a
909
- // load-bearing side effect: `imuGate.resetAnchor()` bounds the
910
- // IIR-integrator drift window per-accept, and
911
- // `imuGate.getTotalAbsMetres()` is read at finalize time
912
- // (Camera.tsx:1097) as `imuTranslationMetres` into the native
873
+ // F8.3 review-of-review (M3 revert): an earlier draft gated
874
+ // this on the pre-v0.6 `legacyDriver` prop because the Frame
875
+ // Processor driver doesn't consult `imuGate` for its own pose
876
+ // synthesis. That ignored a load-bearing side effect:
877
+ // `imuGate.resetAnchor()` bounds the IIR-integrator drift
878
+ // window per-accept, and `imuGate.getTotalAbsMetres()` is read
879
+ // at finalize time as `imuTranslationMetres` into the native
913
880
  // stitchMode auto-resolver (PANORAMA vs SCANS). Without the
914
881
  // per-accept reset, long FP-driver captures let IIR drift
915
- // compound → inflated metres → biased toward SCANS. Keep the
916
- // reset firing for ALL non-AR modes.
882
+ // compound → inflated metres → biased toward SCANS. Now fires
883
+ // for ALL non-AR captures (the only non-AR driver post-v0.6).
917
884
  if (isNonAR) {
918
885
  imuGate.resetAnchor();
919
886
  }
@@ -1044,13 +1011,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
1044
1011
  snapshotEveryNAccepts: 1,
1045
1012
  frameRotationDegrees: orientationRotation,
1046
1013
  captureOrientation: deviceOrientation,
1047
- // F8.3 — non-AR captures pick between the new Frame Processor
1048
- // driver (default) and the legacy JS-snapshot driver (opt-in
1049
- // via `legacyDriver={true}`). AR captures always use the
1050
- // ARSession-driven path.
1051
- frameSourceMode: isNonAR
1052
- ? (legacyDriver ? 'jsDriver' : 'frameProcessor')
1053
- : 'arSession',
1014
+ // Non-AR captures use the Frame Processor driver
1015
+ // (vision-camera producer-thread worklet cv_flow_gate
1016
+ // plugin → IncrementalStitcher.consumeFrame). AR captures
1017
+ // use the ARSession-driven path.
1018
+ frameSourceMode: isNonAR ? 'frameProcessor' : 'arSession',
1054
1019
  composeWidth: 1920,
1055
1020
  composeHeight: 1080,
1056
1021
  canvasWidth: 5000,
@@ -1066,21 +1031,13 @@ export function Camera(props: CameraProps): React.JSX.Element {
1066
1031
  // matching comment on the per-accept reset useEffect above).
1067
1032
  // Keep firing it on every capture start, not just legacy mode.
1068
1033
  imuGate.resetAnchor();
1069
- // Start the non-AR frame source. AR mode feeds natively from
1070
- // ARSession so both drivers stay idle in that path.
1071
- // * Default: Frame Processor driver worklet runs on the
1072
- // producer thread, plugin calls `consumeFrameFromPlugin`
1073
- // directly. No camera ref needed (vision-camera owns it).
1074
- // * Legacy: JS driver — `takeSnapshot` + `processFrameAtPath`
1075
- // via the cameraRef.
1076
- // Imperative-pattern rationale: see the useIncrementalJSDriver
1077
- // comment above re. why this isn't a useEffect.
1034
+ // Start the Frame Processor driver for non-AR captures. AR
1035
+ // mode feeds natively from ARSession so the driver stays idle.
1036
+ // Imperative pattern (vs useEffect) because the driver's start
1037
+ // resets pose accumulators that should only fire at the
1038
+ // hold-start moment, not on every re-render.
1078
1039
  if (isNonAR) {
1079
- if (legacyDriver) {
1080
- jsDriver.start(visionCameraRef);
1081
- } else {
1082
- fpDriver.start();
1083
- }
1040
+ fpDriver.start();
1084
1041
  }
1085
1042
  } catch (err) {
1086
1043
  setStatusPhase('idle');
@@ -1100,9 +1057,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1100
1057
  settings,
1101
1058
  effectiveCaptureSource,
1102
1059
  imuGate,
1103
- jsDriver,
1104
1060
  fpDriver,
1105
- legacyDriver,
1106
1061
  engine,
1107
1062
  onError,
1108
1063
  ]);
@@ -1112,10 +1067,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1112
1067
  setStatusPhase('stitching');
1113
1068
  // Stop pumping new frames before finalizing so the engine isn't
1114
1069
  // racing the final cv::Stitcher pass against late-arriving
1115
- // keyframes. Both stop() calls are no-ops when the
1116
- // corresponding driver wasn't started (AR mode, or the inactive
1117
- // driver in non-AR mode).
1118
- jsDriver.stop();
1070
+ // keyframes. No-op in AR mode (the driver was never started).
1119
1071
  fpDriver.stop();
1120
1072
  try {
1121
1073
  // Compose the panorama output path: host-controlled if
@@ -1193,7 +1145,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
1193
1145
  onFramesDropped,
1194
1146
  onError,
1195
1147
  recordingStartedAt,
1196
- jsDriver,
1197
1148
  fpDriver,
1198
1149
  // F10 Phase 2 review N1 — these four were missing pre-fix. The
1199
1150
  // callback reads `settings.debug` (to gate the stitchToast),
package/src/index.ts CHANGED
@@ -175,16 +175,16 @@ export {
175
175
  getIncrementalNativeModule,
176
176
  cleanupOldKeyframes,
177
177
  } from './stitching/incremental';
178
- export type { IncrementalState } from './stitching/incremental';
178
+ export type { IncrementalState, AcceptedKeyframe } from './stitching/incremental';
179
179
  export { useIncrementalStitcher } from './stitching/useIncrementalStitcher';
180
- export { useIncrementalJSDriver } from './stitching/useIncrementalJSDriver';
181
- export type {
182
- UseIncrementalJSDriverOptions,
183
- IncrementalJSDriverHandle,
184
- } from './stitching/useIncrementalJSDriver';
185
- // F8.3 — vision-camera Frame Processor variant of the non-AR
186
- // driver. Preferred over `useIncrementalJSDriver` in v0.5+; the
187
- // JS driver stays exported as a deprecated fallback until v0.6.
180
+ // v0.7.0 Tier 1 subscriber API. Fires on each accepted keyframe
181
+ // in batch-keyframe captures (see hook's docstring for engine-mode
182
+ // caveat). Foundation for plugin-pattern host features (OCR per
183
+ // keyframe, packet detection, server-side analysis, etc.).
184
+ export { useKeyframeStream } from './stitching/useKeyframeStream';
185
+ // vision-camera Frame Processor driver for non-AR captures. As
186
+ // of v0.6 the only non-AR driver exported (the legacy
187
+ // `useIncrementalJSDriver` was removed; was deprecated in v0.5).
188
188
  export { useFrameProcessorDriver } from './stitching/useFrameProcessorDriver';
189
189
  export type {
190
190
  UseFrameProcessorDriverOptions,
@@ -61,6 +61,53 @@ export enum IncrementalOutcome {
61
61
  }
62
62
 
63
63
 
64
+ /**
65
+ * v0.7.0 (Tier 1) — public payload type for an accepted keyframe.
66
+ * Delivered to subscribers of the `useKeyframeStream` hook.
67
+ *
68
+ * Emits once per keyframe accepted by the stitching engine — typically
69
+ * 4-6 times per panorama, not per camera frame. Use for low-frequency
70
+ * per-keyframe host work (OCR on the saved JPEG, packet detection,
71
+ * server-side analysis, analytics, etc.).
72
+ *
73
+ * Caveat: only the `batch-keyframe` engine emits these events as of
74
+ * v0.7.0. Live engines (`firstwins-rectilinear`, `hybrid`,
75
+ * `slitscan-*`) paint into a live canvas instead of saving per-accept
76
+ * JPEGs and do not currently surface accept events through this
77
+ * channel; the hook silently does not fire there. A v0.7.1 follow-up
78
+ * may add live-engine accept emit if a real consumer needs it.
79
+ *
80
+ * The JPEG at `jpegPath` is the engine's own copy under the active
81
+ * capture's session directory. It persists for the lifetime of the
82
+ * panorama and is cleaned up automatically when the panorama finalises
83
+ * or is abandoned (or via explicit `cleanupKeyframes`). Host code
84
+ * wanting to retain it long-term must copy synchronously inside the
85
+ * handler.
86
+ */
87
+ export interface AcceptedKeyframe {
88
+ /** Absolute filesystem path to the keyframe JPEG. No `file://` prefix. */
89
+ jpegPath: string;
90
+
91
+ /**
92
+ * Pose snapshot at the moment of acceptance. Quaternion
93
+ * convention: `(x, y, z, w)`; lib uses
94
+ * `q = q_yaw * q_pitch * q_roll`. Translation in metres (world
95
+ * coords) is present in AR mode and undefined in non-AR mode (no
96
+ * spatial anchor — only gyro-derived rotation is available).
97
+ */
98
+ pose: {
99
+ rotation: [number, number, number, number];
100
+ translation?: [number, number, number];
101
+ };
102
+
103
+ /** Milliseconds since the Unix epoch when the engine accepted this keyframe. */
104
+ timestamp: number;
105
+
106
+ /** Zero-based index of this keyframe within the in-progress panorama. */
107
+ index: number;
108
+ }
109
+
110
+
64
111
  export interface IncrementalState {
65
112
  /**
66
113
  * Path to the latest panorama snapshot JPEG (file path, no
@@ -152,6 +199,33 @@ export interface IncrementalState {
152
199
  * for the thumbnail strip.
153
200
  */
154
201
  batchKeyframeIndex?: number;
202
+ /**
203
+ * v0.7.0 (Tier 1) — pose snapshot at the moment the engine
204
+ * accepted this keyframe. Populated alongside
205
+ * `batchKeyframeThumbnailPath` + `batchKeyframeIndex` on the
206
+ * keyframe-accepted state emit from the `batch-keyframe` engine.
207
+ * Undefined for other engines and for non-accept events.
208
+ *
209
+ * Quaternion convention: `(x, y, z, w)`; lib uses
210
+ * `q = q_yaw * q_pitch * q_roll`. AR mode populates `translation`
211
+ * from the AR camera transform (metres, world coords). Non-AR
212
+ * mode omits `translation` (no spatial anchor — only gyro-derived
213
+ * rotation is available).
214
+ *
215
+ * Foundation for the `useKeyframeStream` Tier 1 host hook.
216
+ */
217
+ batchKeyframePose?: {
218
+ rotation: [number, number, number, number];
219
+ translation?: [number, number, number];
220
+ };
221
+ /**
222
+ * v0.7.0 (Tier 1) — monotonic timestamp (milliseconds since the
223
+ * Unix epoch) when the engine accepted this keyframe. Populated
224
+ * alongside the other `batchKeyframe*` fields on the
225
+ * keyframe-accepted emit. Undefined for other engines and for
226
+ * non-accept events.
227
+ */
228
+ batchKeyframeAcceptedAtMs?: number;
155
229
  /**
156
230
  * 2026-05-16 — realtime+batch fusion (Option A "Replace on
157
231
  * completion"). True between the moment a hybrid-engine
@@ -200,23 +274,22 @@ export interface IncrementalStartOptions {
200
274
  * bridge.start() requires `RNSARSession.start()` to
201
275
  * have already been called.
202
276
  *
203
- * - 'jsDriver' — engine skips AR-session registration; JS
204
- * feeds frames via `processFrameAtPath`. Use in iOS non-AR
205
- * captures (vision-camera + gyro). No AR session required.
206
- * LEGACY; deprecated in v0.5, removed in v0.6.
207
- *
208
277
  * - 'frameProcessor' (F8.3 iOS / F8.4 Android, v0.5+) — engine
209
278
  * flips on `frameProcessorIngestEnabled` so the vision-camera
210
279
  * Frame Processor plugin (`cv_flow_gate_process_frame`) can
211
280
  * feed pixel data directly into the engine's gate path. iOS
212
281
  * passes the `CVPixelBuffer` straight to `consumeFrame`;
213
- * Android extracts the Y plane to a ByteArray and encodes
214
- * accepted frames to JPEG inline (the platform-specific
215
- * engine-input divergence is tracked as F8.6). Use in non-AR
216
- * captures driven by `useFrameProcessorDriver`. Pairs with
217
- * `Camera`'s default driver mode.
282
+ * Android extracts the Y plane to a ByteArray and (since
283
+ * F8.6, v0.5.1) routes live-engine ingest through
284
+ * `addFramePixelData` without a JPEG round-trip. Use in
285
+ * non-AR captures driven by `useFrameProcessorDriver`. Pairs
286
+ * with `Camera`'s default driver mode.
287
+ *
288
+ * `'jsDriver'` was removed in v0.6 (deprecated in v0.5). Hosts
289
+ * that used it should switch to `useFrameProcessorDriver` (or
290
+ * just let `<Camera>` use its default).
218
291
  */
219
- frameSourceMode?: 'arSession' | 'jsDriver' | 'frameProcessor';
292
+ frameSourceMode?: 'arSession' | 'frameProcessor';
220
293
  /** Compose-resolution width in pixels (default 720 for portrait, 960 for landscape). */
221
294
  composeWidth?: number;
222
295
  /** Compose-resolution height in pixels (default 960 for portrait, 720 for landscape). */
@@ -1,14 +1,15 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * useFrameProcessorDriver — vision-camera Frame Processor + gyro
4
- * driver for the incremental panorama engine. Replaces
5
- * `useIncrementalJSDriver` in non-AR captures.
4
+ * driver for the incremental panorama engine. Sole non-AR driver
5
+ * from v0.6 onward (replaced the deprecated `useIncrementalJSDriver`
6
+ * hook, which was removed in v0.6).
6
7
  *
7
- * Why this exists (vs the JS-driver predecessor)
8
+ * Why this exists (vs the pre-v0.6 JS-driver predecessor)
8
9
  *
9
- * The JS driver takes a JPEG snapshot every ~250 ms and feeds the
10
- * path to `IncrementalStitcher.processFrameAtPath`. That path
11
- * has three costs:
10
+ * The old JS driver took a JPEG snapshot every ~250 ms and fed the
11
+ * path to `IncrementalStitcher.processFrameAtPath` (both removed in
12
+ * v0.6). That path had three costs:
12
13
  *
13
14
  * 1. JPEG encode (`takeSnapshot` ≈ 30–80 ms on iPhone 16 Pro)
14
15
  * 2. Disk write of the JPEG
@@ -303,9 +304,9 @@ export function useFrameProcessorDriver(
303
304
  // y = horizontal pan (yaw, about world-Y)
304
305
  // x = vertical tilt (pitch, about world-X)
305
306
  // z = wrist-twist roll (about world-Z, normal to the screen)
306
- // Signs match the legacy `useIncrementalJSDriver` for x/y; z
307
- // follows the same right-hand-rule convention. If field
308
- // captures show inverted roll, flip the sign on `z * dt` below.
307
+ // Right-hand-rule convention throughout same signs the pre-v0.6
308
+ // `useIncrementalJSDriver` produced. If field captures show
309
+ // inverted roll, flip the sign on `z * dt` below.
309
310
  setUpdateIntervalForType(SensorTypes.gyroscope, gyroIntervalMs);
310
311
  gyroSubRef.current = gyroscope.subscribe({
311
312
  next: ({ x, y, z }) => {
@@ -382,8 +383,8 @@ export function useFrameProcessorDriver(
382
383
  imageWidth: w, imageHeight: h,
383
384
  timestampMs: 0,
384
385
  // 2 == RNSARTrackingState.tracking — we always claim "good
385
- // tracking" because there's no ARKit signal to differentiate
386
- // (matches legacy useIncrementalJSDriver semantics).
386
+ // tracking" because there's no ARKit signal to differentiate.
387
+ // (Same contract as the pre-v0.6 useIncrementalJSDriver.)
387
388
  trackingStateRaw: 2,
388
389
  });
389
390
  // Deps array intentionally minimal: only `plugin` actually