@movementinfra/expo-twostep-video 0.1.11 → 0.1.12

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/README.md CHANGED
@@ -126,6 +126,7 @@ function VideoEditor() {
126
126
  | `adjustSpeed({ assetId, speed, startTime?, endTime? })` | Change speed | `VideoComposition` |
127
127
  | `loopSegment({ assetId, startTime, endTime, loopCount })` | Loop a segment | `LoopResult` |
128
128
  | `transformVideo({ assetId, speed?, mirrorAxis?, startTime?, endTime? })` | Combined transform | `VideoComposition` |
129
+ | `panZoomVideo({ assetId, panX, panY, zoomLevel })` | Apply pan/zoom crop | `VideoComposition` |
129
130
  | `generateThumbnails({ assetId, times, size? })` | Extract frames | `string[]` (base64) |
130
131
  | `exportVideo({ compositionId, quality? })` | Export composition | `ExportResult` |
131
132
  | `exportAsset({ assetId, quality? })` | Export asset directly | `ExportResult` |
@@ -420,6 +421,7 @@ function VideoPlayer({ compositionId }: { compositionId: string }) {
420
421
  | `onProgress` | `function` | Called periodically with `{ currentTime, duration, progress }` |
421
422
  | `onEnd` | `function` | Called when video ends (not called if `loop` is true) |
422
423
  | `onError` | `function` | Called on playback error |
424
+ | `onPanZoomChange` | `function` | Called when user pinches/pans with `{ panX, panY, zoomLevel }` |
423
425
 
424
426
  #### Player Methods (via ref)
425
427
 
@@ -439,6 +441,205 @@ await playerRef.current?.seek(10.5);
439
441
  await playerRef.current?.replay();
440
442
  ```
441
443
 
444
+ ### Pan & Zoom
445
+
446
+ The `TwoStepVideoView` component supports interactive pan and zoom gestures, allowing users to zoom into video content and pan around while zoomed.
447
+
448
+ #### Gesture Controls
449
+
450
+ | Gesture | Action |
451
+ |---------|--------|
452
+ | Pinch (2 fingers) | Zoom in/out (1x to 5x) |
453
+ | Drag (2 fingers) | Pan around when zoomed in |
454
+
455
+ #### Pan/Zoom Events
456
+
457
+ Listen for pan/zoom state changes with the `onPanZoomChange` prop:
458
+
459
+ ```tsx
460
+ import { TwoStepVideoView, PanZoomState } from 'expo-twostep-video';
461
+
462
+ function VideoWithZoom() {
463
+ const [panZoom, setPanZoom] = useState<PanZoomState>({
464
+ panX: 0,
465
+ panY: 0,
466
+ zoomLevel: 1,
467
+ });
468
+
469
+ return (
470
+ <View>
471
+ <TwoStepVideoView
472
+ assetId={asset.id}
473
+ onPanZoomChange={(e) => setPanZoom(e.nativeEvent)}
474
+ style={{ width: '100%', height: 300 }}
475
+ />
476
+
477
+ {panZoom.zoomLevel > 1 && (
478
+ <Text>
479
+ Zoom: {panZoom.zoomLevel.toFixed(1)}x |
480
+ Pan: ({panZoom.panX.toFixed(2)}, {panZoom.panY.toFixed(2)})
481
+ </Text>
482
+ )}
483
+ </View>
484
+ );
485
+ }
486
+ ```
487
+
488
+ #### Pan/Zoom Methods (via ref)
489
+
490
+ Control pan/zoom programmatically using the player ref:
491
+
492
+ ```typescript
493
+ const playerRef = useRef<TwoStepVideoViewRef>(null);
494
+
495
+ // Get current pan/zoom state
496
+ const state = await playerRef.current?.getPanZoomState();
497
+ // Returns: { panX: number, panY: number, zoomLevel: number }
498
+
499
+ // Set pan/zoom programmatically
500
+ await playerRef.current?.setPanZoomState({
501
+ panX: 0.5, // Pan right (range: -1 to 1)
502
+ panY: -0.3, // Pan up (range: -1 to 1)
503
+ zoomLevel: 2.0 // 2x zoom (range: 1 to 5)
504
+ });
505
+
506
+ // Reset to default (no zoom, centered)
507
+ await playerRef.current?.resetPanZoom();
508
+ ```
509
+
510
+ #### Baking Pan/Zoom into Export
511
+
512
+ To permanently apply the pan/zoom transform to a video for export, use `panZoomVideo`:
513
+
514
+ ```typescript
515
+ import * as TwoStepVideo from 'expo-twostep-video';
516
+
517
+ // Apply pan/zoom as a permanent transformation
518
+ const composition = await TwoStepVideo.panZoomVideo({
519
+ assetId: asset.id,
520
+ panX: 0.3, // Pan position (-1 to 1)
521
+ panY: -0.2,
522
+ zoomLevel: 1.5, // Zoom level (1 to 5)
523
+ });
524
+
525
+ // Export the cropped/zoomed video
526
+ const result = await TwoStepVideo.exportVideo({
527
+ compositionId: composition.id,
528
+ quality: 'high',
529
+ });
530
+ ```
531
+
532
+ #### Complete Pan/Zoom Example
533
+
534
+ A full implementation with gesture preview and export:
535
+
536
+ ```tsx
537
+ import React, { useState, useRef } from 'react';
538
+ import { View, Button, Text, StyleSheet } from 'react-native';
539
+ import * as TwoStepVideo from 'expo-twostep-video';
540
+ import {
541
+ TwoStepVideoView,
542
+ TwoStepVideoViewRef,
543
+ PanZoomState,
544
+ } from 'expo-twostep-video';
545
+
546
+ function PanZoomEditor({ assetId }: { assetId: string }) {
547
+ const playerRef = useRef<TwoStepVideoViewRef>(null);
548
+ const [panZoom, setPanZoom] = useState<PanZoomState>({
549
+ panX: 0,
550
+ panY: 0,
551
+ zoomLevel: 1,
552
+ });
553
+
554
+ const hasTransform = panZoom.zoomLevel > 1 ||
555
+ panZoom.panX !== 0 ||
556
+ panZoom.panY !== 0;
557
+
558
+ const handleReset = async () => {
559
+ await playerRef.current?.resetPanZoom();
560
+ setPanZoom({ panX: 0, panY: 0, zoomLevel: 1 });
561
+ };
562
+
563
+ const handleApplyAndExport = async () => {
564
+ // Bake the pan/zoom into a composition
565
+ const composition = await TwoStepVideo.panZoomVideo({
566
+ assetId,
567
+ panX: panZoom.panX,
568
+ panY: panZoom.panY,
569
+ zoomLevel: panZoom.zoomLevel,
570
+ });
571
+
572
+ // Export the result
573
+ const result = await TwoStepVideo.exportVideo({
574
+ compositionId: composition.id,
575
+ quality: 'high',
576
+ });
577
+
578
+ // Reset the preview since transform is now baked in
579
+ await handleReset();
580
+
581
+ console.log('Exported to:', result.uri);
582
+ };
583
+
584
+ return (
585
+ <View style={styles.container}>
586
+ {/* Video Preview with Gestures */}
587
+ <TwoStepVideoView
588
+ ref={playerRef}
589
+ assetId={assetId}
590
+ onPanZoomChange={(e) => setPanZoom(e.nativeEvent)}
591
+ style={styles.video}
592
+ />
593
+
594
+ {/* Gesture Hint */}
595
+ <Text style={styles.hint}>
596
+ Pinch to zoom, drag with 2 fingers to pan
597
+ </Text>
598
+
599
+ {/* Transform Info */}
600
+ {hasTransform && (
601
+ <View style={styles.info}>
602
+ <Text>Zoom: {panZoom.zoomLevel.toFixed(2)}x</Text>
603
+ <Text>Pan X: {panZoom.panX.toFixed(3)}</Text>
604
+ <Text>Pan Y: {panZoom.panY.toFixed(3)}</Text>
605
+ </View>
606
+ )}
607
+
608
+ {/* Controls */}
609
+ <View style={styles.buttons}>
610
+ <Button
611
+ title="Reset"
612
+ onPress={handleReset}
613
+ disabled={!hasTransform}
614
+ />
615
+ <Button
616
+ title="Apply & Export"
617
+ onPress={handleApplyAndExport}
618
+ disabled={!hasTransform}
619
+ />
620
+ </View>
621
+ </View>
622
+ );
623
+ }
624
+
625
+ const styles = StyleSheet.create({
626
+ container: { flex: 1, padding: 16 },
627
+ video: { width: '100%', height: 300, backgroundColor: '#000' },
628
+ hint: { textAlign: 'center', color: '#666', marginTop: 8 },
629
+ info: { padding: 12, backgroundColor: '#f0f0f0', borderRadius: 8, marginTop: 12 },
630
+ buttons: { flexDirection: 'row', justifyContent: 'space-around', marginTop: 16 },
631
+ });
632
+
633
+ export default PanZoomEditor;
634
+ ```
635
+
636
+ #### Pan/Zoom Notes
637
+
638
+ - **Zoom range**: 1x (no zoom) to 5x by default
639
+ - **Pan range**: -1 to 1 on both axes (only effective when zoomed in)
640
+ - **Pan constraint**: Pan is automatically constrained to keep video content visible at the current zoom level
641
+ - **Simulator testing**: Multi-touch gestures work best on real devices. The iOS Simulator uses Option+drag for pinch, which can be unreliable
642
+
442
643
  ### Events
443
644
 
444
645
  #### `addExportProgressListener(callback)`
@@ -570,6 +771,7 @@ interface TwoStepVideoViewProps {
570
771
  onProgress?: (event: { nativeEvent: ProgressEvent }) => void;
571
772
  onEnd?: (event: { nativeEvent: {} }) => void;
572
773
  onError?: (event: { nativeEvent: ErrorEvent }) => void;
774
+ onPanZoomChange?: (event: { nativeEvent: PanZoomState }) => void;
573
775
  style?: ViewStyle;
574
776
  }
575
777
 
@@ -578,6 +780,25 @@ interface TwoStepVideoViewRef {
578
780
  pause: () => Promise<void>;
579
781
  seek: (time: number) => Promise<void>;
580
782
  replay: () => Promise<void>;
783
+ getPanZoomState: () => Promise<PanZoomState>;
784
+ setPanZoomState: (state: Partial<PanZoomState>) => Promise<void>;
785
+ resetPanZoom: () => Promise<void>;
786
+ }
787
+
788
+ // Pan/Zoom types
789
+ interface PanZoomState {
790
+ panX: number; // Horizontal pan (-1 to 1, 0 = center)
791
+ panY: number; // Vertical pan (-1 to 1, 0 = center)
792
+ zoomLevel: number; // Zoom level (1 to 5, 1 = no zoom)
793
+ }
794
+
795
+ interface PanZoomVideoOptions {
796
+ assetId: string;
797
+ panX: number;
798
+ panY: number;
799
+ zoomLevel: number;
800
+ startTime?: number;
801
+ endTime?: number;
581
802
  }
582
803
  ```
583
804
 
@@ -42,8 +42,7 @@ class ExpoTwoStepVideoView: ExpoView {
42
42
  private var lastPinchScale: CGFloat = 1.0
43
43
 
44
44
  /// Starting pan position when gesture begins
45
- private var panStartX: CGFloat = 0.0
46
- private var panStartY: CGFloat = 0.0
45
+ private var panStartPosition: CGPoint = .zero
47
46
 
48
47
  /// Minimum zoom level (configurable)
49
48
  var minZoom: CGFloat = 1.0
@@ -55,6 +54,25 @@ class ExpoTwoStepVideoView: ExpoView {
55
54
  private var pinchGesture: UIPinchGestureRecognizer?
56
55
  private var panGesture: UIPanGestureRecognizer?
57
56
 
57
+ // MARK: - Pan/Zoom Helpers
58
+
59
+ /// Maximum pan amount allowed at current zoom level
60
+ /// At zoom 2x, this is 0.5 (can pan halfway). At zoom 1x, this is 0.
61
+ private var maxPanAmount: CGFloat {
62
+ guard currentZoom > 1.0 else { return 0 }
63
+ return (currentZoom - 1.0) / currentZoom
64
+ }
65
+
66
+ /// Clamp a value between min and max bounds
67
+ private func clamp(_ value: CGFloat, min minValue: CGFloat, max maxValue: CGFloat) -> CGFloat {
68
+ return Swift.min(Swift.max(value, minValue), maxValue)
69
+ }
70
+
71
+ /// Whether pan/zoom is at the default (untransformed) state
72
+ private var isAtDefaultTransform: Bool {
73
+ return currentZoom == 1.0 && currentPanX == 0 && currentPanY == 0
74
+ }
75
+
58
76
  // MARK: - Initialization
59
77
 
60
78
  required init(appContext: AppContext? = nil) {
@@ -183,19 +201,16 @@ class ExpoTwoStepVideoView: ExpoView {
183
201
  case .began:
184
202
  lastPinchScale = 1.0
185
203
  case .changed:
186
- // Calculate incremental scale change
204
+ // Calculate incremental scale change from last gesture update
187
205
  let scaleChange = gesture.scale / lastPinchScale
188
206
  lastPinchScale = gesture.scale
189
207
 
190
- // Apply to current zoom
191
- let newZoom = currentZoom * scaleChange
192
- currentZoom = min(max(newZoom, minZoom), maxZoom)
208
+ // Apply zoom with clamping to valid range
209
+ currentZoom = clamp(currentZoom * scaleChange, min: minZoom, max: maxZoom)
193
210
 
194
- // Constrain pan when zoom changes
211
+ // Constrain pan (may need adjustment when zoom decreases)
195
212
  constrainPan()
196
-
197
- updateLayerTransform()
198
- emitPanZoomChange()
213
+ applyTransformAndNotify()
199
214
  case .ended, .cancelled:
200
215
  lastPinchScale = 1.0
201
216
  default:
@@ -209,29 +224,23 @@ class ExpoTwoStepVideoView: ExpoView {
209
224
 
210
225
  switch gesture.state {
211
226
  case .began:
212
- panStartX = currentPanX
213
- panStartY = currentPanY
227
+ panStartPosition = CGPoint(x: currentPanX, y: currentPanY)
214
228
  case .changed:
215
229
  let translation = gesture.translation(in: self)
216
230
 
217
- // Convert translation to normalized pan values
218
- // When zoomed in 2x, a full-width drag should change pan by the available pan range
219
- let availablePanX = (currentZoom - 1.0) / currentZoom
220
- let availablePanY = (currentZoom - 1.0) / currentZoom
231
+ // Convert screen translation to normalized pan delta
232
+ // Dragging full width changes pan by 2x the available pan range
233
+ let panDelta = CGPoint(
234
+ x: (translation.x / bounds.width) * 2 * maxPanAmount,
235
+ y: (translation.y / bounds.height) * 2 * maxPanAmount
236
+ )
221
237
 
222
- // Normalize translation to view size
223
- let normalizedDeltaX = translation.x / bounds.width
224
- let normalizedDeltaY = translation.y / bounds.height
238
+ // Apply delta from start position (negative because dragging right shows left content)
239
+ currentPanX = panStartPosition.x - panDelta.x
240
+ currentPanY = panStartPosition.y - panDelta.y
225
241
 
226
- // Scale by available pan range and apply
227
- currentPanX = panStartX - normalizedDeltaX * 2 * availablePanX
228
- currentPanY = panStartY - normalizedDeltaY * 2 * availablePanY
229
-
230
- // Constrain pan
231
242
  constrainPan()
232
-
233
- updateLayerTransform()
234
- emitPanZoomChange()
243
+ applyTransformAndNotify()
235
244
  case .ended, .cancelled:
236
245
  break
237
246
  default:
@@ -239,39 +248,35 @@ class ExpoTwoStepVideoView: ExpoView {
239
248
  }
240
249
  }
241
250
 
242
- /// Constrain pan values so content stays visible
251
+ /// Constrain pan values to keep content visible at current zoom level
243
252
  private func constrainPan() {
244
- // When zoomed, limit how far we can pan
245
- // At zoom level 2x, we can pan at most to show the edge (normalized to -1...1)
246
- let maxPanAmount = (currentZoom - 1.0) / currentZoom
247
-
248
- currentPanX = min(max(currentPanX, -maxPanAmount), maxPanAmount)
249
- currentPanY = min(max(currentPanY, -maxPanAmount), maxPanAmount)
253
+ let limit = maxPanAmount
254
+ currentPanX = clamp(currentPanX, min: -limit, max: limit)
255
+ currentPanY = clamp(currentPanY, min: -limit, max: limit)
256
+ }
250
257
 
251
- // If not zoomed in, reset pan to center
252
- if currentZoom <= 1.0 {
253
- currentPanX = 0
254
- currentPanY = 0
255
- }
258
+ /// Apply transform to layer and notify JS of the change
259
+ private func applyTransformAndNotify() {
260
+ updateLayerTransform()
261
+ emitPanZoomChange()
256
262
  }
257
263
 
258
264
  /// Apply the current pan/zoom transform to the player layer
259
265
  private func updateLayerTransform() {
260
266
  guard let layer = playerLayer else { return }
261
267
 
262
- var transform = CATransform3DIdentity
268
+ // Start with identity and apply zoom
269
+ var transform = CATransform3DScale(CATransform3DIdentity, currentZoom, currentZoom, 1.0)
263
270
 
264
- // Apply zoom (scale)
265
- transform = CATransform3DScale(transform, currentZoom, currentZoom, 1.0)
266
-
267
- // Apply pan (translation) - scale the translation by zoom to account for scaled coordinate space
268
- let translateX = -currentPanX * bounds.width * (currentZoom - 1.0) / (2.0 * currentZoom)
269
- let translateY = -currentPanY * bounds.height * (currentZoom - 1.0) / (2.0 * currentZoom)
271
+ // Apply pan translation (scaled for zoomed coordinate space)
272
+ // Formula: translate by half the extra visible area in the zoom direction
273
+ let translateX = -currentPanX * bounds.width * maxPanAmount / 2.0
274
+ let translateY = -currentPanY * bounds.height * maxPanAmount / 2.0
270
275
  transform = CATransform3DTranslate(transform, translateX, translateY, 0)
271
276
 
272
- // Apply transform with animation for smoothness
277
+ // Apply without implicit animations for responsive feel
273
278
  CATransaction.begin()
274
- CATransaction.setDisableActions(true) // Disable implicit animations for responsiveness
279
+ CATransaction.setDisableActions(true)
275
280
  layer.transform = transform
276
281
  CATransaction.commit()
277
282
  }
@@ -299,7 +304,7 @@ class ExpoTwoStepVideoView: ExpoView {
299
304
  /// Set the pan/zoom state programmatically
300
305
  func setPanZoomState(panX: CGFloat?, panY: CGFloat?, zoomLevel: CGFloat?) {
301
306
  if let zoom = zoomLevel {
302
- currentZoom = min(max(zoom, minZoom), maxZoom)
307
+ currentZoom = clamp(zoom, min: minZoom, max: maxZoom)
303
308
  }
304
309
  if let x = panX {
305
310
  currentPanX = x
@@ -309,8 +314,7 @@ class ExpoTwoStepVideoView: ExpoView {
309
314
  }
310
315
 
311
316
  constrainPan()
312
- updateLayerTransform()
313
- emitPanZoomChange()
317
+ applyTransformAndNotify()
314
318
  }
315
319
 
316
320
  /// Reset pan/zoom to default state
@@ -318,15 +322,14 @@ class ExpoTwoStepVideoView: ExpoView {
318
322
  currentZoom = 1.0
319
323
  currentPanX = 0.0
320
324
  currentPanY = 0.0
321
- updateLayerTransform()
322
- emitPanZoomChange()
325
+ applyTransformAndNotify()
323
326
  }
324
327
 
325
328
  override func layoutSubviews() {
326
329
  super.layoutSubviews()
327
330
  playerLayer?.frame = bounds
328
- // Reapply transform after frame changes
329
- if currentZoom != 1.0 || currentPanX != 0 || currentPanY != 0 {
331
+ // Reapply transform after frame changes (only if not at default)
332
+ if !isAtDefaultTransform {
330
333
  updateLayerTransform()
331
334
  }
332
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@movementinfra/expo-twostep-video",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Minimal video editing for React Native using AVFoundation",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",