npm package: @movementinfra/expo-twostep-video
Description: Minimal video editing for React Native using AVFoundation
Author: rguo123
Latest version: 0.1.17

# expo-twostep-video

Professional video editing for React Native, powered by native AVFoundation.

[![npm version](https://badge.fury.io/js/expo-twostep-video.svg)](https://www.npmjs.com/package/expo-twostep-video)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

## Features

- **Native video picker** with album support, iCloud downloading, and rich metadata
- **Trim videos** with frame-accurate precision
- **Mirror videos** horizontally, vertically, or both
- **Speed adjustment** - slow motion (0.25x) to fast forward (4x)
- **Loop segments** - repeat video sections for perfect loops
- **Pan & zoom** - pinch-to-zoom with gesture controls
- **Multi-segment compositions** - create highlight reels
- **Generate thumbnails** at any timestamp
- **Native video player** with playback controls
- **Export** with customizable quality settings
- **Real-time progress** tracking during exports
- **Type-safe TypeScript API** with full IntelliSense

## Requirements

- iOS 15.0+
- Expo SDK 50+
- React Native 0.72+

## Installation

```bash
npx expo install expo-twostep-video
```

## Quick Start

```typescript
import * as TwoStepVideo from 'expo-twostep-video';

// Pick a video from the library
const videos = await TwoStepVideo.pickVideo();
if (videos.length === 0) return; // User cancelled

const video = videos[0];
console.log(`Selected: ${video.fileName} (${video.duration}s)`);

// Load the video for editing
const asset = await TwoStepVideo.loadAsset({ uri: video.uri });

// Trim to 10 seconds
const composition = await TwoStepVideo.trimVideo({
  assetId: asset.id,
  startTime: 5.0,
  endTime: 15.0
});

// Export
const result = await TwoStepVideo.exportVideo({
  compositionId: composition.id,
  quality: TwoStepVideo.Quality.HIGH
});

console.log('Exported to:', result.uri);

// Cleanup
TwoStepVideo.cleanupPickedVideo(video.path);
TwoStepVideo.releaseAll();
```

## API Reference

### Picking Videos from Library

Native photo library picker with full album support, iCloud downloading, and rich metadata:

```typescript
// Pick a single video
const videos = await TwoStepVideo.pickVideo();

if (videos.length > 0) {
  const video = videos[0];
  console.log('File:', video.fileName);
  console.log('Size:', video.width, 'x', video.height);
  console.log('Duration:', video.duration, 'seconds');
  console.log('File size:', video.fileSize, 'bytes');
  console.log('Created:', video.creationDate);  // ISO 8601

  // Load for editing
  const asset = await TwoStepVideo.loadAsset({ uri: video.uri });

  // ... edit video ...

  // Clean up temp file when done
  TwoStepVideo.cleanupPickedVideo(video.path);
}

// Pick multiple videos
const videos = await TwoStepVideo.pickVideo({ selectionLimit: 5 });

// Clean up all picked videos
TwoStepVideo.cleanupAllPickedVideos();
```

**Returned metadata (`PickedVideo`):**

| Field | Type | Description |
|-------|------|-------------|
| `uri` | `string` | File URI to temp copy |
| `path` | `string` | Absolute file path (for cleanup) |
| `fileName` | `string` | Original filename |
| `width` | `number` | Video width in pixels |
| `height` | `number` | Video height in pixels |
| `duration` | `number` | Duration in seconds |
| `fileSize` | `number` | File size in bytes |
| `creationDate` | `string?` | Original capture date (ISO 8601) |
| `modificationDate` | `string?` | Last modified date (ISO 8601) |
| `assetIdentifier` | `string?` | PHAsset ID for `loadAssetFromPhotos` |
| `type` | `string` | Always `"video"` |

**Features:**
- Native iOS picker with albums, favorites, and search
- Automatic iCloud video downloading with native progress UI
- Natural permission flow (prompts when needed)
- Comprehensive metadata including original creation date

### Loading Videos

```typescript
// Load from file URI
const asset = await TwoStepVideo.loadAsset({ uri: 'file:///path/to/video.mp4' });
// Returns: { id, duration, width, height, frameRate, hasAudio }

// Load from Photos library
const asset = await TwoStepVideo.loadAssetFromPhotos(photoAsset.id);

// Validate without loading
const isValid = await TwoStepVideo.validateVideoUri(uri);
```

### Trimming

```typescript
// Single segment
const composition = await TwoStepVideo.trimVideo({
  assetId: asset.id,
  startTime: 5.0,
  endTime: 15.0
});

// Multiple segments (highlight reel)
const composition = await TwoStepVideo.trimVideoMultiple({
  assetId: asset.id,
  segments: [
    { start: 0, end: 3 },
    { start: 10, end: 15 },
    { start: 20, end: 25 }
  ]
});
```

### Mirroring

```typescript
// Horizontal (flip left-right) - common for selfie videos
const mirrored = await TwoStepVideo.mirrorVideo({
  assetId: asset.id,
  axis: 'horizontal'
});

// Vertical (flip top-bottom)
const flipped = await TwoStepVideo.mirrorVideo({
  assetId: asset.id,
  axis: 'vertical'
});

// Both axes
const both = await TwoStepVideo.mirrorVideo({
  assetId: asset.id,
  axis: 'both'
});
```

### Speed Adjustment

```typescript
// Slow motion (0.5x = 2x slower)
const slowMo = await TwoStepVideo.adjustSpeed({
  assetId: asset.id,
  speed: 0.5
});

// Fast forward (2x speed)
const fast = await TwoStepVideo.adjustSpeed({
  assetId: asset.id,
  speed: 2.0
});

// Timelapse (4x speed)
const timelapse = await TwoStepVideo.adjustSpeed({
  assetId: asset.id,
  speed: 4.0
});
```

### Looping

```typescript
// Loop a 3-second segment 4 times (plays 5 times total = 15 seconds)
const looped = await TwoStepVideo.loopSegment({
  assetId: asset.id,
  startTime: 0,
  endTime: 3,
  loopCount: 4
});

console.log(`Duration: ${looped.duration}s, plays ${looped.totalPlays} times`);
```

### Combined Transformations

```typescript
// Mirror + slow motion in one operation
const transformed = await TwoStepVideo.transformVideo({
  assetId: asset.id,
  speed: 0.5,
  mirrorAxis: 'horizontal',
  startTime: 0,
  endTime: 10
});
```

### Pan & Zoom

```typescript
// Apply pan/zoom for export (bakes transform into video)
const zoomed = await TwoStepVideo.panZoomVideo({
  assetId: asset.id,
  panX: 0.3,       // Pan position (-1 to 1)
  panY: -0.2,
  zoomLevel: 1.5   // Zoom level (1 to 5)
});
```

### Thumbnails

```typescript
const thumbnails = await TwoStepVideo.generateThumbnails({
  assetId: asset.id,
  times: [0, 5, 10, 15],
  size: { width: 300, height: 300 }
});

// Use in Image component
<Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />
```

### Exporting

```typescript
// Export composition
const result = await TwoStepVideo.exportVideo({
  compositionId: composition.id,
  quality: TwoStepVideo.Quality.HIGH
});

// Export asset directly
const result = await TwoStepVideo.exportAsset({
  assetId: asset.id,
  quality: TwoStepVideo.Quality.MEDIUM
});

// Track progress
const subscription = TwoStepVideo.addExportProgressListener((event) => {
  console.log(`${Math.round(event.progress * 100)}%`);
});
// Later: subscription.remove();
```

### Memory Management

```typescript
// Release loaded assets and compositions
TwoStepVideo.releaseAsset(asset.id);
TwoStepVideo.releaseComposition(composition.id);
TwoStepVideo.releaseAll();  // Release everything

// Clean up files
TwoStepVideo.cleanupFile(result.uri);           // Delete exported temp file
TwoStepVideo.cleanupPickedVideo(video.path);    // Delete picked video temp file
TwoStepVideo.cleanupAllPickedVideos();          // Delete all picked video temp files
```

## Video Scrubber Component

### VideoScrubber

A native-style video scrubber/trimmer with thumbnail strip, draggable trim handles, and a draggable playhead for frame-accurate seeking:

```tsx
import { VideoScrubber, VideoScrubberRef } from 'expo-twostep-video';

function VideoEditor({ asset }: { asset: VideoAsset }) {
  const playerRef = useRef<TwoStepVideoViewRef>(null);
  const [trimStart, setTrimStart] = useState(0);
  const [trimEnd, setTrimEnd] = useState(asset.duration);
  const [currentTime, setCurrentTime] = useState(0);

  return (
    <View>
      <TwoStepVideoView
        ref={playerRef}
        assetId={asset.id}
        onProgress={(e) => setCurrentTime(e.nativeEvent.currentTime)}
      />

      <VideoScrubber
        assetId={asset.id}
        duration={asset.duration}
        currentTime={currentTime}
        startTime={trimStart}
        endTime={trimEnd}
        onStartTimeChange={setTrimStart}
        onEndTimeChange={setTrimEnd}
        onScrub={(time) => playerRef.current?.seek(time)}
        onScrubbing={(time) => playerRef.current?.seek(time)}
      />
    </View>
  );
}
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `assetId` | `string` | Asset ID for thumbnail generation |
| `duration` | `number` | Total video duration in seconds |
| `currentTime` | `number` | Current playback position (for playhead) |
| `startTime` | `number` | Selected start time in seconds |
| `endTime` | `number` | Selected end time in seconds |
| `thumbnailCount` | `number` | Number of thumbnails (default: 10) |
| `thumbnailHeight` | `number` | Height of thumbnail strip (default: 50) |
| `onStartTimeChange` | `function` | Called when start handle moves |
| `onEndTimeChange` | `function` | Called when end handle moves |
| `onScrub` | `function` | Called when playhead is tapped or dragged |
| `onScrubbing` | `function` | Called continuously during any drag |
| `onScrubEnd` | `function` | Called when drag ends |
| `minDuration` | `number` | Minimum selection duration (default: 0.5) |
| `showPlayhead` | `boolean` | Show playhead indicator (default: true) |
| `handleWidth` | `number` | Handle width in pixels (default: 20) |
| `theme` | `object` | Custom colors for handles, playhead, etc. |

**Features:**
- Drag the **yellow handles** to adjust trim start/end times
- Drag the **white playhead** to scrub through the video
- Tap anywhere in the selection to seek to that position
- All drag operations call `onScrubbing` for smooth video preview

## Video Player Component

### TwoStepVideoView

Native video player with pan/zoom gesture support:

```tsx
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';

function VideoPlayer({ compositionId }: { compositionId: string }) {
  const playerRef = useRef<TwoStepVideoViewRef>(null);

  return (
    <View>
      <TwoStepVideoView
        ref={playerRef}
        compositionId={compositionId}
        loop={false}
        onPlaybackStatusChange={(e) => console.log(e.nativeEvent.status)}
        onProgress={(e) => console.log(e.nativeEvent.progress)}
        onPanZoomChange={(e) => console.log(e.nativeEvent.zoomLevel)}
        style={{ width: '100%', height: 300 }}
      />

      <Button title="Play" onPress={() => playerRef.current?.play()} />
      <Button title="Pause" onPress={() => playerRef.current?.pause()} />
      <Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
    </View>
  );
}
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `compositionId` | `string` | ID from trimVideo, mirrorVideo, etc. |
| `assetId` | `string` | ID from loadAsset (for raw playback) |
| `loop` | `boolean` | Enable continuous looping |
| `minZoom` | `number` | Minimum zoom level (default: 1.0) |
| `maxZoom` | `number` | Maximum zoom level (default: 5.0) |
| `onPlaybackStatusChange` | `function` | Status: ready, playing, paused, ended |
| `onProgress` | `function` | Progress: currentTime, duration, progress |
| `onPanZoomChange` | `function` | Pan/zoom: panX, panY, zoomLevel |
| `onEnd` | `function` | Called when playback ends |
| `onError` | `function` | Called on error |

**Ref Methods:**

```typescript
await playerRef.current?.play();
await playerRef.current?.pause();
await playerRef.current?.seek(10.5);
await playerRef.current?.replay();

// Pan/zoom control
const state = await playerRef.current?.getPanZoomState();
await playerRef.current?.setPanZoomState({ zoomLevel: 2.0 });
await playerRef.current?.resetPanZoom();
```

### TwoStepPlayerControllerView

Native iOS player with system controls (AirPlay, PiP, fullscreen):

```tsx
import { TwoStepPlayerControllerView } from 'expo-twostep-video';

<TwoStepPlayerControllerView
  assetId={asset.id}
  showsPlaybackControls={true}
  style={{ width: '100%', height: 300 }}
/>
```

## Constants

### Quality Presets

```typescript
TwoStepVideo.Quality.LOW      // Fast, small files
TwoStepVideo.Quality.MEDIUM   // Good for web/social
TwoStepVideo.Quality.HIGH     // Recommended default
TwoStepVideo.Quality.HIGHEST  // Archival quality
```

### Mirror Axis

```typescript
TwoStepVideo.Mirror.HORIZONTAL  // Flip left-right
TwoStepVideo.Mirror.VERTICAL    // Flip top-bottom
TwoStepVideo.Mirror.BOTH        // Flip both
```

## TypeScript Types

```typescript
import type {
  // Picker types
  PickedVideo,
  PickVideoOptions,
  // Asset types
  VideoAsset,
  VideoComposition,
  ExportResult,
  LoopResult,
  // View types
  TwoStepVideoViewRef,
  TwoStepVideoViewProps,
  PanZoomState,
  // Scrubber types
  VideoScrubberProps,
  VideoScrubberRef,
  VideoScrubberTheme,
  // Constants
  MirrorAxis,
  VideoQuality,
} from 'expo-twostep-video';
```

## Platform Support

- ✅ iOS (fully supported)
- ⏳ Android (coming soon)

## License

MIT © Richard Guo

## Links

- [GitHub Repository](https://github.com/rguo123/twostep-video)
- [Issue Tracker](https://github.com/rguo123/twostep-video/issues)