@movementinfra/expo-twostep-video 0.1.12 โ 0.1.13
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 +136 -1034
- package/build/ExpoTwoStepVideo.types.d.ts +9 -3
- package/build/ExpoTwoStepVideo.types.d.ts.map +1 -1
- package/build/ExpoTwoStepVideo.types.js.map +1 -1
- package/build/ExpoTwoStepVideoModule.d.ts +2 -5
- package/build/ExpoTwoStepVideoModule.d.ts.map +1 -1
- package/build/ExpoTwoStepVideoModule.js.map +1 -1
- package/build/TwoStepPlayerControllerView.d.ts.map +1 -1
- package/build/TwoStepPlayerControllerView.js +0 -10
- package/build/TwoStepPlayerControllerView.js.map +1 -1
- package/build/index.d.ts +3 -18
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -12
- package/build/index.js.map +1 -1
- package/ios/ExpoTwoStepVideoView.swift +257 -149
- package/package.json +1 -1
- /package/ios/{ExpoTwostepPlayerControllerView.swift โ ExpoTwoStepPlayerControllerView.swift} +0 -0
package/README.md
CHANGED
|
@@ -7,20 +7,23 @@ Professional video editing for React Native, powered by native AVFoundation.
|
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
10
|
+
- **Trim videos** with frame-accurate precision
|
|
11
|
+
- **Mirror videos** horizontally, vertically, or both
|
|
12
|
+
- **Speed adjustment** - slow motion (0.25x) to fast forward (4x)
|
|
13
|
+
- **Loop segments** - repeat video sections for perfect loops
|
|
14
|
+
- **Pan & zoom** - pinch-to-zoom with gesture controls
|
|
15
|
+
- **Multi-segment compositions** - create highlight reels
|
|
16
|
+
- **Generate thumbnails** at any timestamp
|
|
17
|
+
- **Native video player** with playback controls
|
|
18
|
+
- **Export** with customizable quality settings
|
|
19
|
+
- **Real-time progress** tracking during exports
|
|
20
|
+
- **Type-safe TypeScript API** with full IntelliSense
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- iOS 15.0+
|
|
25
|
+
- Expo SDK 50+
|
|
26
|
+
- React Native 0.72+
|
|
24
27
|
|
|
25
28
|
## Installation
|
|
26
29
|
|
|
@@ -38,634 +41,262 @@ const asset = await TwoStepVideo.loadAsset({
|
|
|
38
41
|
uri: 'file:///path/to/video.mp4'
|
|
39
42
|
});
|
|
40
43
|
|
|
41
|
-
// Trim
|
|
44
|
+
// Trim to 10 seconds
|
|
42
45
|
const composition = await TwoStepVideo.trimVideo({
|
|
43
46
|
assetId: asset.id,
|
|
44
47
|
startTime: 5.0,
|
|
45
48
|
endTime: 15.0
|
|
46
49
|
});
|
|
47
50
|
|
|
48
|
-
// Export
|
|
51
|
+
// Export
|
|
49
52
|
const result = await TwoStepVideo.exportVideo({
|
|
50
53
|
compositionId: composition.id,
|
|
51
54
|
quality: TwoStepVideo.Quality.HIGH
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
console.log('Exported to:', result.uri);
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Complete Example
|
|
58
|
-
|
|
59
|
-
```typescript
|
|
60
|
-
import React, { useState, useEffect } from 'react';
|
|
61
|
-
import { View, Button, Text } from 'react-native';
|
|
62
|
-
import * as TwoStepVideo from 'expo-twostep-video';
|
|
63
|
-
|
|
64
|
-
function VideoEditor() {
|
|
65
|
-
const [progress, setProgress] = useState(0);
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
const subscription = TwoStepVideo.addExportProgressListener((event) => {
|
|
69
|
-
setProgress(event.progress);
|
|
70
|
-
});
|
|
71
|
-
return () => subscription.remove();
|
|
72
|
-
}, []);
|
|
73
|
-
|
|
74
|
-
const trimAndExport = async () => {
|
|
75
|
-
try {
|
|
76
|
-
// Load
|
|
77
|
-
const asset = await TwoStepVideo.loadAsset({
|
|
78
|
-
uri: 'file:///path/to/video.mp4'
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Trim
|
|
82
|
-
const composition = await TwoStepVideo.trimVideo({
|
|
83
|
-
assetId: asset.id,
|
|
84
|
-
startTime: 0,
|
|
85
|
-
endTime: 10
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Export
|
|
89
|
-
const result = await TwoStepVideo.exportVideo({
|
|
90
|
-
compositionId: composition.id,
|
|
91
|
-
quality: TwoStepVideo.Quality.HIGH
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
alert(`Saved to: ${result.uri}`);
|
|
95
|
-
|
|
96
|
-
// Cleanup
|
|
97
|
-
TwoStepVideo.releaseAsset(asset.id);
|
|
98
|
-
TwoStepVideo.releaseComposition(composition.id);
|
|
99
|
-
|
|
100
|
-
} catch (error) {
|
|
101
|
-
console.error('Error:', error);
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
58
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<Text>Progress: {Math.round(progress * 100)}%</Text>
|
|
108
|
-
<Button title="Trim & Export" onPress={trimAndExport} />
|
|
109
|
-
</View>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
59
|
+
// Cleanup
|
|
60
|
+
TwoStepVideo.releaseAll();
|
|
112
61
|
```
|
|
113
62
|
|
|
114
63
|
## API Reference
|
|
115
64
|
|
|
116
|
-
### Quick Reference
|
|
117
|
-
|
|
118
|
-
| Function | Description | Returns |
|
|
119
|
-
|----------|-------------|---------|
|
|
120
|
-
| `loadAsset({ uri })` | Load video from file URI | `VideoAsset` |
|
|
121
|
-
| `loadAssetFromPhotos(id)` | Load from Photos library | `VideoAsset` |
|
|
122
|
-
| `validateVideoUri(uri)` | Validate without loading | `boolean` |
|
|
123
|
-
| `trimVideo({ assetId, startTime, endTime })` | Trim to time range | `VideoComposition` |
|
|
124
|
-
| `trimVideoMultiple({ assetId, segments })` | Multi-segment trim | `VideoComposition` |
|
|
125
|
-
| `mirrorVideo({ assetId, axis, startTime?, endTime? })` | Mirror/flip video | `VideoComposition` |
|
|
126
|
-
| `adjustSpeed({ assetId, speed, startTime?, endTime? })` | Change speed | `VideoComposition` |
|
|
127
|
-
| `loopSegment({ assetId, startTime, endTime, loopCount })` | Loop a segment | `LoopResult` |
|
|
128
|
-
| `transformVideo({ assetId, speed?, mirrorAxis?, startTime?, endTime? })` | Combined transform | `VideoComposition` |
|
|
129
|
-
| `panZoomVideo({ assetId, panX, panY, zoomLevel })` | Apply pan/zoom crop | `VideoComposition` |
|
|
130
|
-
| `generateThumbnails({ assetId, times, size? })` | Extract frames | `string[]` (base64) |
|
|
131
|
-
| `exportVideo({ compositionId, quality? })` | Export composition | `ExportResult` |
|
|
132
|
-
| `exportAsset({ assetId, quality? })` | Export asset directly | `ExportResult` |
|
|
133
|
-
| `releaseAsset(id)` | Free memory | `void` |
|
|
134
|
-
| `releaseComposition(id)` | Free memory | `void` |
|
|
135
|
-
| `releaseAll()` | Free all memory | `void` |
|
|
136
|
-
|
|
137
65
|
### Loading Videos
|
|
138
66
|
|
|
139
|
-
#### `loadAsset(options)`
|
|
140
|
-
Load a video from a file URI.
|
|
141
|
-
|
|
142
67
|
```typescript
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
68
|
+
// Load from file URI
|
|
69
|
+
const asset = await TwoStepVideo.loadAsset({ uri: 'file:///path/to/video.mp4' });
|
|
146
70
|
// Returns: { id, duration, width, height, frameRate, hasAudio }
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
#### `loadAssetFromPhotos(localIdentifier)`
|
|
150
|
-
Load a video from the Photos library.
|
|
151
71
|
|
|
152
|
-
|
|
72
|
+
// Load from Photos library
|
|
153
73
|
const asset = await TwoStepVideo.loadAssetFromPhotos(photoAsset.id);
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
#### `validateVideoUri(uri)`
|
|
157
|
-
Quickly validate a video URI without loading.
|
|
158
74
|
|
|
159
|
-
|
|
75
|
+
// Validate without loading
|
|
160
76
|
const isValid = await TwoStepVideo.validateVideoUri(uri);
|
|
161
77
|
```
|
|
162
78
|
|
|
163
79
|
### Trimming
|
|
164
80
|
|
|
165
|
-
#### `trimVideo(options)`
|
|
166
|
-
Trim to a single time range.
|
|
167
|
-
|
|
168
81
|
```typescript
|
|
82
|
+
// Single segment
|
|
169
83
|
const composition = await TwoStepVideo.trimVideo({
|
|
170
84
|
assetId: asset.id,
|
|
171
|
-
startTime: 5.0,
|
|
85
|
+
startTime: 5.0,
|
|
172
86
|
endTime: 15.0
|
|
173
87
|
});
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
#### `trimVideoMultiple(options)`
|
|
177
|
-
Create a composition from multiple segments.
|
|
178
88
|
|
|
179
|
-
|
|
89
|
+
// Multiple segments (highlight reel)
|
|
180
90
|
const composition = await TwoStepVideo.trimVideoMultiple({
|
|
181
91
|
assetId: asset.id,
|
|
182
92
|
segments: [
|
|
183
|
-
{ start: 0, end:
|
|
93
|
+
{ start: 0, end: 3 },
|
|
184
94
|
{ start: 10, end: 15 },
|
|
185
95
|
{ start: 20, end: 25 }
|
|
186
96
|
]
|
|
187
97
|
});
|
|
188
98
|
```
|
|
189
99
|
|
|
190
|
-
###
|
|
191
|
-
|
|
192
|
-
#### `generateThumbnails(options)`
|
|
193
|
-
Extract thumbnail images at specific times.
|
|
194
|
-
|
|
195
|
-
```typescript
|
|
196
|
-
const thumbnails = await TwoStepVideo.generateThumbnails({
|
|
197
|
-
assetId: asset.id,
|
|
198
|
-
times: [0, 5, 10, 15],
|
|
199
|
-
size: { width: 300, height: 300 }
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// Use in Image component
|
|
203
|
-
<Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### Video Mirroring
|
|
207
|
-
|
|
208
|
-
#### `mirrorVideo(options)`
|
|
209
|
-
Mirror (flip) a video horizontally, vertically, or both. Perfect for selfie videos that need to be flipped.
|
|
100
|
+
### Mirroring
|
|
210
101
|
|
|
211
102
|
```typescript
|
|
212
|
-
//
|
|
103
|
+
// Horizontal (flip left-right) - common for selfie videos
|
|
213
104
|
const mirrored = await TwoStepVideo.mirrorVideo({
|
|
214
105
|
assetId: asset.id,
|
|
215
106
|
axis: 'horizontal'
|
|
216
107
|
});
|
|
217
108
|
|
|
218
|
-
//
|
|
109
|
+
// Vertical (flip top-bottom)
|
|
219
110
|
const flipped = await TwoStepVideo.mirrorVideo({
|
|
220
111
|
assetId: asset.id,
|
|
221
112
|
axis: 'vertical'
|
|
222
113
|
});
|
|
223
114
|
|
|
224
|
-
//
|
|
115
|
+
// Both axes
|
|
225
116
|
const both = await TwoStepVideo.mirrorVideo({
|
|
226
117
|
assetId: asset.id,
|
|
227
118
|
axis: 'both'
|
|
228
119
|
});
|
|
229
|
-
|
|
230
|
-
// Mirror only a specific segment (5s to 10s)
|
|
231
|
-
const partialMirror = await TwoStepVideo.mirrorVideo({
|
|
232
|
-
assetId: asset.id,
|
|
233
|
-
axis: 'horizontal',
|
|
234
|
-
startTime: 5,
|
|
235
|
-
endTime: 10
|
|
236
|
-
});
|
|
237
120
|
```
|
|
238
121
|
|
|
239
122
|
### Speed Adjustment
|
|
240
123
|
|
|
241
|
-
#### `adjustSpeed(options)`
|
|
242
|
-
Change the playback speed of a video. Supports slow motion (< 1.0) and fast forward (> 1.0).
|
|
243
|
-
|
|
244
124
|
```typescript
|
|
245
|
-
// Slow motion (0.5x = 2x slower
|
|
125
|
+
// Slow motion (0.5x = 2x slower)
|
|
246
126
|
const slowMo = await TwoStepVideo.adjustSpeed({
|
|
247
127
|
assetId: asset.id,
|
|
248
128
|
speed: 0.5
|
|
249
129
|
});
|
|
250
130
|
|
|
251
|
-
//
|
|
252
|
-
const
|
|
253
|
-
assetId: asset.id,
|
|
254
|
-
speed: 0.25
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Fast forward (2x speed, video becomes half as long)
|
|
258
|
-
const fastForward = await TwoStepVideo.adjustSpeed({
|
|
131
|
+
// Fast forward (2x speed)
|
|
132
|
+
const fast = await TwoStepVideo.adjustSpeed({
|
|
259
133
|
assetId: asset.id,
|
|
260
134
|
speed: 2.0
|
|
261
135
|
});
|
|
262
136
|
|
|
263
|
-
// Timelapse
|
|
137
|
+
// Timelapse (4x speed)
|
|
264
138
|
const timelapse = await TwoStepVideo.adjustSpeed({
|
|
265
139
|
assetId: asset.id,
|
|
266
140
|
speed: 4.0
|
|
267
141
|
});
|
|
268
|
-
|
|
269
|
-
// Speed up only a specific segment (10s to 30s becomes a quick timelapse)
|
|
270
|
-
const partialSpeed = await TwoStepVideo.adjustSpeed({
|
|
271
|
-
assetId: asset.id,
|
|
272
|
-
speed: 4.0,
|
|
273
|
-
startTime: 10,
|
|
274
|
-
endTime: 30
|
|
275
|
-
});
|
|
276
142
|
```
|
|
277
143
|
|
|
278
|
-
###
|
|
279
|
-
|
|
280
|
-
#### `loopSegment(options)`
|
|
281
|
-
Loop a segment of video multiple times. Great for creating perfect loops for social media or extending short clips.
|
|
144
|
+
### Looping
|
|
282
145
|
|
|
283
146
|
```typescript
|
|
284
|
-
// Loop a
|
|
147
|
+
// Loop a 3-second segment 4 times (plays 5 times total = 15 seconds)
|
|
285
148
|
const looped = await TwoStepVideo.loopSegment({
|
|
286
|
-
assetId: asset.id,
|
|
287
|
-
startTime: 5,
|
|
288
|
-
endTime: 7,
|
|
289
|
-
loopCount: 3 // Repeats 3 times after first play
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
console.log(`Duration: ${looped.duration}s`); // 8 seconds
|
|
293
|
-
console.log(`Total plays: ${looped.totalPlays}`); // 4 times
|
|
294
|
-
|
|
295
|
-
// Create a 15-second loop from a 3-second clip for Instagram/TikTok
|
|
296
|
-
const socialLoop = await TwoStepVideo.loopSegment({
|
|
297
149
|
assetId: asset.id,
|
|
298
150
|
startTime: 0,
|
|
299
151
|
endTime: 3,
|
|
300
|
-
loopCount: 4
|
|
152
|
+
loopCount: 4
|
|
301
153
|
});
|
|
302
154
|
|
|
303
|
-
|
|
304
|
-
const bestMoment = await TwoStepVideo.loopSegment({
|
|
305
|
-
assetId: asset.id,
|
|
306
|
-
startTime: 12.5, // Start at 12.5 seconds
|
|
307
|
-
endTime: 14.0, // 1.5 second clip
|
|
308
|
-
loopCount: 9 // 1.5s * 10 plays = 15 seconds
|
|
309
|
-
});
|
|
155
|
+
console.log(`Duration: ${looped.duration}s, plays ${looped.totalPlays} times`);
|
|
310
156
|
```
|
|
311
157
|
|
|
312
158
|
### Combined Transformations
|
|
313
159
|
|
|
314
|
-
#### `transformVideo(options)`
|
|
315
|
-
Apply multiple transformations in a single operation: trim, mirror, and speed adjustment combined.
|
|
316
|
-
|
|
317
160
|
```typescript
|
|
318
|
-
// Mirror
|
|
161
|
+
// Mirror + slow motion in one operation
|
|
319
162
|
const transformed = await TwoStepVideo.transformVideo({
|
|
320
163
|
assetId: asset.id,
|
|
321
164
|
speed: 0.5,
|
|
322
|
-
mirrorAxis: 'horizontal'
|
|
165
|
+
mirrorAxis: 'horizontal',
|
|
166
|
+
startTime: 0,
|
|
167
|
+
endTime: 10
|
|
323
168
|
});
|
|
169
|
+
```
|
|
324
170
|
|
|
325
|
-
|
|
326
|
-
const mirrored = await TwoStepVideo.transformVideo({
|
|
327
|
-
assetId: asset.id,
|
|
328
|
-
mirrorAxis: 'both'
|
|
329
|
-
});
|
|
171
|
+
### Pan & Zoom
|
|
330
172
|
|
|
331
|
-
|
|
332
|
-
|
|
173
|
+
```typescript
|
|
174
|
+
// Apply pan/zoom for export (bakes transform into video)
|
|
175
|
+
const zoomed = await TwoStepVideo.panZoomVideo({
|
|
333
176
|
assetId: asset.id,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
endTime: 10 // Take first 10 seconds, mirror it, speed it up
|
|
177
|
+
panX: 0.3, // Pan position (-1 to 1)
|
|
178
|
+
panY: -0.2,
|
|
179
|
+
zoomLevel: 1.5 // Zoom level (1 to 5)
|
|
338
180
|
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Thumbnails
|
|
339
184
|
|
|
340
|
-
|
|
341
|
-
const
|
|
185
|
+
```typescript
|
|
186
|
+
const thumbnails = await TwoStepVideo.generateThumbnails({
|
|
342
187
|
assetId: asset.id,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
endTime: 15
|
|
188
|
+
times: [0, 5, 10, 15],
|
|
189
|
+
size: { width: 300, height: 300 }
|
|
346
190
|
});
|
|
191
|
+
|
|
192
|
+
// Use in Image component
|
|
193
|
+
<Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />
|
|
347
194
|
```
|
|
348
195
|
|
|
349
196
|
### Exporting
|
|
350
197
|
|
|
351
|
-
#### `exportVideo(options)`
|
|
352
|
-
Export a composition to a file.
|
|
353
|
-
|
|
354
198
|
```typescript
|
|
199
|
+
// Export composition
|
|
355
200
|
const result = await TwoStepVideo.exportVideo({
|
|
356
201
|
compositionId: composition.id,
|
|
357
|
-
quality: TwoStepVideo.Quality.HIGH
|
|
358
|
-
outputUri: 'file:///path/to/output.mp4' // optional
|
|
202
|
+
quality: TwoStepVideo.Quality.HIGH
|
|
359
203
|
});
|
|
360
|
-
```
|
|
361
204
|
|
|
362
|
-
|
|
363
|
-
Export an asset directly (without trimming).
|
|
364
|
-
|
|
365
|
-
```typescript
|
|
205
|
+
// Export asset directly
|
|
366
206
|
const result = await TwoStepVideo.exportAsset({
|
|
367
207
|
assetId: asset.id,
|
|
368
208
|
quality: TwoStepVideo.Quality.MEDIUM
|
|
369
209
|
});
|
|
210
|
+
|
|
211
|
+
// Track progress
|
|
212
|
+
const subscription = TwoStepVideo.addExportProgressListener((event) => {
|
|
213
|
+
console.log(`${Math.round(event.progress * 100)}%`);
|
|
214
|
+
});
|
|
215
|
+
// Later: subscription.remove();
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Memory Management
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
TwoStepVideo.releaseAsset(asset.id);
|
|
222
|
+
TwoStepVideo.releaseComposition(composition.id);
|
|
223
|
+
TwoStepVideo.releaseAll(); // Release everything
|
|
224
|
+
TwoStepVideo.cleanupFile(result.uri); // Delete temp file
|
|
370
225
|
```
|
|
371
226
|
|
|
372
|
-
|
|
227
|
+
## Video Player Component
|
|
228
|
+
|
|
229
|
+
### TwoStepVideoView
|
|
373
230
|
|
|
374
|
-
|
|
375
|
-
A native video player component for playing assets and compositions with full playback control.
|
|
231
|
+
Native video player with pan/zoom gesture support:
|
|
376
232
|
|
|
377
233
|
```tsx
|
|
378
234
|
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
|
|
379
|
-
import { useRef, useState } from 'react';
|
|
380
|
-
import { View, Button, Text } from 'react-native';
|
|
381
235
|
|
|
382
236
|
function VideoPlayer({ compositionId }: { compositionId: string }) {
|
|
383
237
|
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
384
|
-
const [status, setStatus] = useState<string>('ready');
|
|
385
|
-
const [progress, setProgress] = useState(0);
|
|
386
238
|
|
|
387
239
|
return (
|
|
388
|
-
<View
|
|
240
|
+
<View>
|
|
389
241
|
<TwoStepVideoView
|
|
390
242
|
ref={playerRef}
|
|
391
243
|
compositionId={compositionId}
|
|
392
244
|
loop={false}
|
|
393
|
-
onPlaybackStatusChange={(e) =>
|
|
394
|
-
onProgress={(e) =>
|
|
395
|
-
|
|
396
|
-
onError={(e) => console.error(e.nativeEvent.error)}
|
|
245
|
+
onPlaybackStatusChange={(e) => console.log(e.nativeEvent.status)}
|
|
246
|
+
onProgress={(e) => console.log(e.nativeEvent.progress)}
|
|
247
|
+
onPanZoomChange={(e) => console.log(e.nativeEvent.zoomLevel)}
|
|
397
248
|
style={{ width: '100%', height: 300 }}
|
|
398
249
|
/>
|
|
399
250
|
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
<
|
|
403
|
-
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
404
|
-
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
405
|
-
<Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
|
|
406
|
-
<Button title="Replay" onPress={() => playerRef.current?.replay()} />
|
|
407
|
-
</View>
|
|
251
|
+
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
252
|
+
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
253
|
+
<Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
|
|
408
254
|
</View>
|
|
409
255
|
);
|
|
410
256
|
}
|
|
411
257
|
```
|
|
412
258
|
|
|
413
|
-
|
|
259
|
+
**Props:**
|
|
414
260
|
|
|
415
261
|
| Prop | Type | Description |
|
|
416
262
|
|------|------|-------------|
|
|
417
|
-
| `compositionId` | `string` | ID from
|
|
418
|
-
| `assetId` | `string` | ID from
|
|
419
|
-
| `loop` | `boolean` | Enable continuous looping
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `onPanZoomChange` | `function` |
|
|
425
|
-
|
|
426
|
-
|
|
263
|
+
| `compositionId` | `string` | ID from trimVideo, mirrorVideo, etc. |
|
|
264
|
+
| `assetId` | `string` | ID from loadAsset (for raw playback) |
|
|
265
|
+
| `loop` | `boolean` | Enable continuous looping |
|
|
266
|
+
| `minZoom` | `number` | Minimum zoom level (default: 1.0) |
|
|
267
|
+
| `maxZoom` | `number` | Maximum zoom level (default: 5.0) |
|
|
268
|
+
| `onPlaybackStatusChange` | `function` | Status: ready, playing, paused, ended |
|
|
269
|
+
| `onProgress` | `function` | Progress: currentTime, duration, progress |
|
|
270
|
+
| `onPanZoomChange` | `function` | Pan/zoom: panX, panY, zoomLevel |
|
|
271
|
+
| `onEnd` | `function` | Called when playback ends |
|
|
272
|
+
| `onError` | `function` | Called on error |
|
|
273
|
+
|
|
274
|
+
**Ref Methods:**
|
|
427
275
|
|
|
428
276
|
```typescript
|
|
429
|
-
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
430
|
-
|
|
431
|
-
// Start playback
|
|
432
277
|
await playerRef.current?.play();
|
|
433
|
-
|
|
434
|
-
// Pause playback
|
|
435
278
|
await playerRef.current?.pause();
|
|
436
|
-
|
|
437
|
-
// Seek to specific time (in seconds)
|
|
438
279
|
await playerRef.current?.seek(10.5);
|
|
439
|
-
|
|
440
|
-
// Restart from beginning
|
|
441
280
|
await playerRef.current?.replay();
|
|
442
|
-
```
|
|
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
281
|
|
|
455
|
-
|
|
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
|
|
282
|
+
// Pan/zoom control
|
|
496
283
|
const state = await playerRef.current?.getPanZoomState();
|
|
497
|
-
|
|
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)
|
|
284
|
+
await playerRef.current?.setPanZoomState({ zoomLevel: 2.0 });
|
|
507
285
|
await playerRef.current?.resetPanZoom();
|
|
508
286
|
```
|
|
509
287
|
|
|
510
|
-
|
|
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
|
|
288
|
+
### TwoStepPlayerControllerView
|
|
533
289
|
|
|
534
|
-
|
|
290
|
+
Native iOS player with system controls (AirPlay, PiP, fullscreen):
|
|
535
291
|
|
|
536
292
|
```tsx
|
|
537
|
-
import
|
|
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
|
-
|
|
643
|
-
### Events
|
|
293
|
+
import { TwoStepPlayerControllerView } from 'expo-twostep-video';
|
|
644
294
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
console.log(`${Math.round(event.progress * 100)}%`);
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
// Remove listener
|
|
654
|
-
subscription.remove();
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
### Memory Management
|
|
658
|
-
|
|
659
|
-
```typescript
|
|
660
|
-
// Release assets when done
|
|
661
|
-
TwoStepVideo.releaseAsset(asset.id);
|
|
662
|
-
TwoStepVideo.releaseComposition(composition.id);
|
|
663
|
-
|
|
664
|
-
// Release all at once
|
|
665
|
-
TwoStepVideo.releaseAll();
|
|
666
|
-
|
|
667
|
-
// Clean up temp files
|
|
668
|
-
TwoStepVideo.cleanupFile(tempFileUri);
|
|
295
|
+
<TwoStepPlayerControllerView
|
|
296
|
+
assetId={asset.id}
|
|
297
|
+
showsPlaybackControls={true}
|
|
298
|
+
style={{ width: '100%', height: 300 }}
|
|
299
|
+
/>
|
|
669
300
|
```
|
|
670
301
|
|
|
671
302
|
## Constants
|
|
@@ -673,575 +304,46 @@ TwoStepVideo.cleanupFile(tempFileUri);
|
|
|
673
304
|
### Quality Presets
|
|
674
305
|
|
|
675
306
|
```typescript
|
|
676
|
-
TwoStepVideo.Quality.LOW
|
|
677
|
-
TwoStepVideo.Quality.MEDIUM
|
|
678
|
-
TwoStepVideo.Quality.HIGH
|
|
679
|
-
TwoStepVideo.Quality.HIGHEST
|
|
307
|
+
TwoStepVideo.Quality.LOW // Fast, small files
|
|
308
|
+
TwoStepVideo.Quality.MEDIUM // Good for web/social
|
|
309
|
+
TwoStepVideo.Quality.HIGH // Recommended default
|
|
310
|
+
TwoStepVideo.Quality.HIGHEST // Archival quality
|
|
680
311
|
```
|
|
681
312
|
|
|
682
313
|
### Mirror Axis
|
|
683
314
|
|
|
684
315
|
```typescript
|
|
685
|
-
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right
|
|
316
|
+
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right
|
|
686
317
|
TwoStepVideo.Mirror.VERTICAL // Flip top-bottom
|
|
687
|
-
TwoStepVideo.Mirror.BOTH // Flip both
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
### Speed Ranges
|
|
691
|
-
|
|
692
|
-
| Speed | Effect | Duration Change |
|
|
693
|
-
|-------|--------|-----------------|
|
|
694
|
-
| 0.25 | Very slow motion | 4x longer |
|
|
695
|
-
| 0.5 | Slow motion | 2x longer |
|
|
696
|
-
| 1.0 | Normal speed | No change |
|
|
697
|
-
| 2.0 | Fast forward | 2x shorter |
|
|
698
|
-
| 4.0 | Timelapse | 4x shorter |
|
|
699
|
-
|
|
700
|
-
## TypeScript Support
|
|
701
|
-
|
|
702
|
-
Full TypeScript definitions included:
|
|
703
|
-
|
|
704
|
-
```typescript
|
|
705
|
-
// Core types
|
|
706
|
-
interface VideoAsset {
|
|
707
|
-
id: string;
|
|
708
|
-
duration: number;
|
|
709
|
-
width: number;
|
|
710
|
-
height: number;
|
|
711
|
-
frameRate: number;
|
|
712
|
-
hasAudio: boolean;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
interface VideoComposition {
|
|
716
|
-
id: string;
|
|
717
|
-
duration: number;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
interface ExportResult {
|
|
721
|
-
uri: string;
|
|
722
|
-
path: string;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Transformation types
|
|
726
|
-
type MirrorAxis = 'horizontal' | 'vertical' | 'both';
|
|
727
|
-
type VideoQuality = 'low' | 'medium' | 'high' | 'highest';
|
|
728
|
-
|
|
729
|
-
interface MirrorVideoOptions {
|
|
730
|
-
assetId: string;
|
|
731
|
-
axis: MirrorAxis;
|
|
732
|
-
startTime?: number; // Optional: mirror only a segment
|
|
733
|
-
endTime?: number;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
interface AdjustSpeedOptions {
|
|
737
|
-
assetId: string;
|
|
738
|
-
speed: number; // 0.25 to 4.0
|
|
739
|
-
startTime?: number;
|
|
740
|
-
endTime?: number;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
interface LoopSegmentOptions {
|
|
744
|
-
assetId: string;
|
|
745
|
-
startTime: number;
|
|
746
|
-
endTime: number;
|
|
747
|
-
loopCount: number; // Repeats after first play
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
interface LoopResult {
|
|
751
|
-
id: string;
|
|
752
|
-
duration: number;
|
|
753
|
-
loopCount: number;
|
|
754
|
-
totalPlays: number; // loopCount + 1
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
interface TransformVideoOptions {
|
|
758
|
-
assetId: string;
|
|
759
|
-
speed?: number;
|
|
760
|
-
mirrorAxis?: MirrorAxis;
|
|
761
|
-
startTime?: number;
|
|
762
|
-
endTime?: number;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Player types
|
|
766
|
-
interface TwoStepVideoViewProps {
|
|
767
|
-
compositionId?: string;
|
|
768
|
-
assetId?: string;
|
|
769
|
-
loop?: boolean;
|
|
770
|
-
onPlaybackStatusChange?: (event: { nativeEvent: PlaybackStatusEvent }) => void;
|
|
771
|
-
onProgress?: (event: { nativeEvent: ProgressEvent }) => void;
|
|
772
|
-
onEnd?: (event: { nativeEvent: {} }) => void;
|
|
773
|
-
onError?: (event: { nativeEvent: ErrorEvent }) => void;
|
|
774
|
-
onPanZoomChange?: (event: { nativeEvent: PanZoomState }) => void;
|
|
775
|
-
style?: ViewStyle;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
interface TwoStepVideoViewRef {
|
|
779
|
-
play: () => Promise<void>;
|
|
780
|
-
pause: () => Promise<void>;
|
|
781
|
-
seek: (time: number) => Promise<void>;
|
|
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;
|
|
802
|
-
}
|
|
803
|
-
```
|
|
804
|
-
|
|
805
|
-
## Advanced Usage
|
|
806
|
-
|
|
807
|
-
### Create a Highlight Reel
|
|
808
|
-
|
|
809
|
-
```typescript
|
|
810
|
-
async function createHighlightReel(videoUri: string) {
|
|
811
|
-
const asset = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
812
|
-
|
|
813
|
-
// Extract 3-second clips evenly throughout video
|
|
814
|
-
const numClips = 5;
|
|
815
|
-
const interval = asset.duration / (numClips + 1);
|
|
816
|
-
|
|
817
|
-
const segments = Array.from({ length: numClips }, (_, i) => ({
|
|
818
|
-
start: interval * (i + 1),
|
|
819
|
-
end: Math.min(interval * (i + 1) + 3, asset.duration)
|
|
820
|
-
}));
|
|
821
|
-
|
|
822
|
-
const composition = await TwoStepVideo.trimVideoMultiple({
|
|
823
|
-
assetId: asset.id,
|
|
824
|
-
segments
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
return await TwoStepVideo.exportVideo({
|
|
828
|
-
compositionId: composition.id,
|
|
829
|
-
quality: TwoStepVideo.Quality.HIGH
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
```
|
|
833
|
-
|
|
834
|
-
### Selfie Video Mirror & Loop
|
|
835
|
-
|
|
836
|
-
A complete example for processing selfie videos with mirror correction and perfect loops:
|
|
837
|
-
|
|
838
|
-
```tsx
|
|
839
|
-
import React, { useState, useRef, useEffect } from 'react';
|
|
840
|
-
import { View, Button, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
|
841
|
-
import * as TwoStepVideo from 'expo-twostep-video';
|
|
842
|
-
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
|
|
843
|
-
|
|
844
|
-
interface Props {
|
|
845
|
-
videoUri: string;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function SelfieVideoEditor({ videoUri }: Props) {
|
|
849
|
-
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
850
|
-
const [asset, setAsset] = useState<TwoStepVideo.VideoAsset | null>(null);
|
|
851
|
-
const [composition, setComposition] = useState<TwoStepVideo.VideoComposition | null>(null);
|
|
852
|
-
const [isProcessing, setIsProcessing] = useState(false);
|
|
853
|
-
const [exportProgress, setExportProgress] = useState(0);
|
|
854
|
-
|
|
855
|
-
// Load the video asset
|
|
856
|
-
useEffect(() => {
|
|
857
|
-
async function loadVideo() {
|
|
858
|
-
const loaded = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
859
|
-
setAsset(loaded);
|
|
860
|
-
}
|
|
861
|
-
loadVideo();
|
|
862
|
-
|
|
863
|
-
return () => {
|
|
864
|
-
// Cleanup on unmount
|
|
865
|
-
TwoStepVideo.releaseAll();
|
|
866
|
-
};
|
|
867
|
-
}, [videoUri]);
|
|
868
|
-
|
|
869
|
-
// Mirror the selfie video (fix the mirror effect from front camera)
|
|
870
|
-
const handleMirror = async () => {
|
|
871
|
-
if (!asset) return;
|
|
872
|
-
setIsProcessing(true);
|
|
873
|
-
|
|
874
|
-
const mirrored = await TwoStepVideo.mirrorVideo({
|
|
875
|
-
assetId: asset.id,
|
|
876
|
-
axis: 'horizontal'
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
setComposition(mirrored);
|
|
880
|
-
setIsProcessing(false);
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
// Create a slow-motion effect
|
|
884
|
-
const handleSlowMo = async () => {
|
|
885
|
-
if (!asset) return;
|
|
886
|
-
setIsProcessing(true);
|
|
887
|
-
|
|
888
|
-
const slowMo = await TwoStepVideo.adjustSpeed({
|
|
889
|
-
assetId: asset.id,
|
|
890
|
-
speed: 0.5 // Half speed
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
setComposition(slowMo);
|
|
894
|
-
setIsProcessing(false);
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
// Mirror + slow motion combined
|
|
898
|
-
const handleMirrorAndSlowMo = async () => {
|
|
899
|
-
if (!asset) return;
|
|
900
|
-
setIsProcessing(true);
|
|
901
|
-
|
|
902
|
-
const transformed = await TwoStepVideo.transformVideo({
|
|
903
|
-
assetId: asset.id,
|
|
904
|
-
speed: 0.5,
|
|
905
|
-
mirrorAxis: 'horizontal'
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
setComposition(transformed);
|
|
909
|
-
setIsProcessing(false);
|
|
910
|
-
};
|
|
911
|
-
|
|
912
|
-
// Create a perfect loop for social media
|
|
913
|
-
const handleCreateLoop = async () => {
|
|
914
|
-
if (!asset) return;
|
|
915
|
-
setIsProcessing(true);
|
|
916
|
-
|
|
917
|
-
// Loop the first 3 seconds to create 15 seconds of content
|
|
918
|
-
const looped = await TwoStepVideo.loopSegment({
|
|
919
|
-
assetId: asset.id,
|
|
920
|
-
startTime: 0,
|
|
921
|
-
endTime: 3,
|
|
922
|
-
loopCount: 4 // 3s * 5 plays = 15 seconds
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
setComposition(looped);
|
|
926
|
-
setIsProcessing(false);
|
|
927
|
-
console.log(`Created ${looped.totalPlays}-play loop, ${looped.duration}s duration`);
|
|
928
|
-
};
|
|
929
|
-
|
|
930
|
-
// Export the result
|
|
931
|
-
const handleExport = async () => {
|
|
932
|
-
if (!composition) return;
|
|
933
|
-
|
|
934
|
-
const subscription = TwoStepVideo.addExportProgressListener((event) => {
|
|
935
|
-
setExportProgress(event.progress);
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
try {
|
|
939
|
-
const result = await TwoStepVideo.exportVideo({
|
|
940
|
-
compositionId: composition.id,
|
|
941
|
-
quality: 'high'
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
alert(`Exported to: ${result.uri}`);
|
|
945
|
-
} finally {
|
|
946
|
-
subscription.remove();
|
|
947
|
-
setExportProgress(0);
|
|
948
|
-
}
|
|
949
|
-
};
|
|
950
|
-
|
|
951
|
-
if (!asset) {
|
|
952
|
-
return <ActivityIndicator size="large" />;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
return (
|
|
956
|
-
<View style={styles.container}>
|
|
957
|
-
{/* Video Player */}
|
|
958
|
-
<TwoStepVideoView
|
|
959
|
-
ref={playerRef}
|
|
960
|
-
compositionId={composition?.id}
|
|
961
|
-
assetId={!composition ? asset.id : undefined}
|
|
962
|
-
loop={true}
|
|
963
|
-
style={styles.video}
|
|
964
|
-
/>
|
|
965
|
-
|
|
966
|
-
{/* Video Info */}
|
|
967
|
-
<Text style={styles.info}>
|
|
968
|
-
Duration: {composition?.duration.toFixed(1) || asset.duration.toFixed(1)}s |
|
|
969
|
-
Size: {asset.width}x{asset.height}
|
|
970
|
-
</Text>
|
|
971
|
-
|
|
972
|
-
{/* Processing Indicator */}
|
|
973
|
-
{isProcessing && <ActivityIndicator size="small" />}
|
|
974
|
-
|
|
975
|
-
{/* Transformation Buttons */}
|
|
976
|
-
<View style={styles.buttons}>
|
|
977
|
-
<Button title="Mirror" onPress={handleMirror} disabled={isProcessing} />
|
|
978
|
-
<Button title="Slow-Mo" onPress={handleSlowMo} disabled={isProcessing} />
|
|
979
|
-
<Button title="Mirror + Slow" onPress={handleMirrorAndSlowMo} disabled={isProcessing} />
|
|
980
|
-
<Button title="Create Loop" onPress={handleCreateLoop} disabled={isProcessing} />
|
|
981
|
-
</View>
|
|
982
|
-
|
|
983
|
-
{/* Playback Controls */}
|
|
984
|
-
<View style={styles.controls}>
|
|
985
|
-
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
986
|
-
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
987
|
-
<Button title="Replay" onPress={() => playerRef.current?.replay()} />
|
|
988
|
-
</View>
|
|
989
|
-
|
|
990
|
-
{/* Export */}
|
|
991
|
-
{composition && (
|
|
992
|
-
<View style={styles.export}>
|
|
993
|
-
<Button title="Export Video" onPress={handleExport} />
|
|
994
|
-
{exportProgress > 0 && (
|
|
995
|
-
<Text>Exporting: {Math.round(exportProgress * 100)}%</Text>
|
|
996
|
-
)}
|
|
997
|
-
</View>
|
|
998
|
-
)}
|
|
999
|
-
</View>
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
const styles = StyleSheet.create({
|
|
1004
|
-
container: { flex: 1, padding: 16 },
|
|
1005
|
-
video: { width: '100%', height: 300, backgroundColor: '#000' },
|
|
1006
|
-
info: { textAlign: 'center', marginVertical: 8 },
|
|
1007
|
-
buttons: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginVertical: 8 },
|
|
1008
|
-
controls: { flexDirection: 'row', justifyContent: 'center', gap: 16 },
|
|
1009
|
-
export: { marginTop: 16, alignItems: 'center' },
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
export default SelfieVideoEditor;
|
|
1013
|
-
```
|
|
1014
|
-
|
|
1015
|
-
### Timestamp-Based Effects
|
|
1016
|
-
|
|
1017
|
-
Apply different effects to specific timestamps/segments of a video:
|
|
1018
|
-
|
|
1019
|
-
```typescript
|
|
1020
|
-
import * as TwoStepVideo from 'expo-twostep-video';
|
|
1021
|
-
|
|
1022
|
-
async function applyTimestampEffects(videoUri: string) {
|
|
1023
|
-
const asset = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
1024
|
-
|
|
1025
|
-
// Example: Mirror only the segment from 5s to 10s
|
|
1026
|
-
const mirroredSegment = await TwoStepVideo.mirrorVideo({
|
|
1027
|
-
assetId: asset.id,
|
|
1028
|
-
axis: 'horizontal',
|
|
1029
|
-
startTime: 5,
|
|
1030
|
-
endTime: 10
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
// Example: Speed up only a boring middle section (10s to 30s at 4x speed)
|
|
1034
|
-
const timelapseMiddle = await TwoStepVideo.adjustSpeed({
|
|
1035
|
-
assetId: asset.id,
|
|
1036
|
-
speed: 4.0,
|
|
1037
|
-
startTime: 10,
|
|
1038
|
-
endTime: 30
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
// Example: Transform a specific segment (trim + mirror + speed)
|
|
1042
|
-
const transformedClip = await TwoStepVideo.transformVideo({
|
|
1043
|
-
assetId: asset.id,
|
|
1044
|
-
speed: 0.5, // Slow motion
|
|
1045
|
-
mirrorAxis: 'horizontal',
|
|
1046
|
-
startTime: 45, // Best moment starts at 45s
|
|
1047
|
-
endTime: 50 // 5 seconds of slow-mo mirrored footage
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
return transformedClip;
|
|
1051
|
-
}
|
|
1052
|
-
```
|
|
1053
|
-
|
|
1054
|
-
### Create Social Media Loop with Mirror
|
|
1055
|
-
|
|
1056
|
-
Perfect for TikTok/Instagram Reels - select a short segment, mirror it, and loop it:
|
|
1057
|
-
|
|
1058
|
-
```typescript
|
|
1059
|
-
async function createSocialMediaLoop(
|
|
1060
|
-
videoUri: string,
|
|
1061
|
-
startTime: number,
|
|
1062
|
-
endTime: number,
|
|
1063
|
-
targetDuration: number = 15
|
|
1064
|
-
) {
|
|
1065
|
-
const asset = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
1066
|
-
|
|
1067
|
-
// First, create the mirrored and trimmed base clip
|
|
1068
|
-
const mirroredClip = await TwoStepVideo.transformVideo({
|
|
1069
|
-
assetId: asset.id,
|
|
1070
|
-
mirrorAxis: 'horizontal',
|
|
1071
|
-
startTime,
|
|
1072
|
-
endTime
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
// Export the mirrored clip first (loopSegment needs the original asset)
|
|
1076
|
-
const exportedMirror = await TwoStepVideo.exportVideo({
|
|
1077
|
-
compositionId: mirroredClip.id,
|
|
1078
|
-
quality: 'high'
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
// Load the exported mirrored video
|
|
1082
|
-
const mirroredAsset = await TwoStepVideo.loadAsset({
|
|
1083
|
-
uri: exportedMirror.uri
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
// Calculate how many loops needed to reach target duration
|
|
1087
|
-
const clipDuration = endTime - startTime;
|
|
1088
|
-
const loopCount = Math.ceil(targetDuration / clipDuration) - 1;
|
|
1089
|
-
|
|
1090
|
-
// Create the loop
|
|
1091
|
-
const looped = await TwoStepVideo.loopSegment({
|
|
1092
|
-
assetId: mirroredAsset.id,
|
|
1093
|
-
startTime: 0,
|
|
1094
|
-
endTime: mirroredAsset.duration,
|
|
1095
|
-
loopCount
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
console.log(`Created ${looped.duration}s loop from ${clipDuration}s clip`);
|
|
1099
|
-
|
|
1100
|
-
// Export final result
|
|
1101
|
-
const result = await TwoStepVideo.exportVideo({
|
|
1102
|
-
compositionId: looped.id,
|
|
1103
|
-
quality: 'high'
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
// Cleanup intermediate files
|
|
1107
|
-
TwoStepVideo.cleanupFile(exportedMirror.uri);
|
|
1108
|
-
|
|
1109
|
-
return result;
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// Usage
|
|
1113
|
-
const socialLoop = await createSocialMediaLoop(
|
|
1114
|
-
'file:///path/to/selfie.mp4',
|
|
1115
|
-
5.0, // Start at 5 seconds
|
|
1116
|
-
8.0, // End at 8 seconds (3 second clip)
|
|
1117
|
-
15 // Target 15 seconds for Instagram
|
|
1118
|
-
);
|
|
318
|
+
TwoStepVideo.Mirror.BOTH // Flip both
|
|
1119
319
|
```
|
|
1120
320
|
|
|
1121
|
-
|
|
321
|
+
## TypeScript Types
|
|
1122
322
|
|
|
1123
323
|
```typescript
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
## Development
|
|
1137
|
-
|
|
1138
|
-
This module contains both the Swift library and Expo bridge in one package.
|
|
1139
|
-
|
|
1140
|
-
### Running Tests
|
|
1141
|
-
|
|
1142
|
-
```bash
|
|
1143
|
-
# Run Swift unit tests
|
|
1144
|
-
npm run test:swift
|
|
1145
|
-
|
|
1146
|
-
# All tests should pass:
|
|
1147
|
-
# โ
69 tests executed, 20 skipped (integration tests)
|
|
1148
|
-
```
|
|
1149
|
-
|
|
1150
|
-
### Building
|
|
1151
|
-
|
|
1152
|
-
```bash
|
|
1153
|
-
# Build TypeScript
|
|
1154
|
-
npm run build
|
|
1155
|
-
|
|
1156
|
-
# Clean build artifacts
|
|
1157
|
-
npm run clean
|
|
1158
|
-
```
|
|
1159
|
-
|
|
1160
|
-
### Running Example App
|
|
1161
|
-
|
|
1162
|
-
```bash
|
|
1163
|
-
cd example
|
|
1164
|
-
npm install
|
|
1165
|
-
npm run ios
|
|
1166
|
-
```
|
|
1167
|
-
|
|
1168
|
-
## Architecture
|
|
1169
|
-
|
|
1170
|
-
```
|
|
1171
|
-
expo-twostep-video/
|
|
1172
|
-
โโโ ios/
|
|
1173
|
-
โ โโโ TwoStepVideo/ # Swift library
|
|
1174
|
-
โ โ โโโ Models/ # Data models
|
|
1175
|
-
โ โ โโโ Core/ # Core functionality
|
|
1176
|
-
โ โ โโโ TwoStepVideo.swift # Main facade
|
|
1177
|
-
โ โโโ Tests/ # Swift unit tests
|
|
1178
|
-
โ โโโ ExpoTwoStepVideoModule.swift # Expo bridge
|
|
1179
|
-
โ โโโ Package.swift # Swift package for testing
|
|
1180
|
-
โโโ src/
|
|
1181
|
-
โ โโโ index.ts # TypeScript API
|
|
1182
|
-
โโโ example/ # Example React Native app
|
|
1183
|
-
โโโ docs/ # Documentation
|
|
324
|
+
import type {
|
|
325
|
+
VideoAsset,
|
|
326
|
+
VideoComposition,
|
|
327
|
+
ExportResult,
|
|
328
|
+
LoopResult,
|
|
329
|
+
TwoStepVideoViewRef,
|
|
330
|
+
TwoStepVideoViewProps,
|
|
331
|
+
PanZoomState,
|
|
332
|
+
MirrorAxis,
|
|
333
|
+
VideoQuality,
|
|
334
|
+
} from 'expo-twostep-video';
|
|
1184
335
|
```
|
|
1185
336
|
|
|
1186
|
-
## Documentation
|
|
1187
|
-
|
|
1188
|
-
- [Architecture](./docs/ARCHITECTURE.md) - Design and implementation details
|
|
1189
|
-
- [Developer Guide](./docs/CLAUDE.md) - Best practices and patterns
|
|
1190
|
-
- [Temp File Management](./docs/TEMP_FILE_MANAGEMENT.md) - File cleanup strategies
|
|
1191
|
-
|
|
1192
|
-
## Requirements
|
|
1193
|
-
|
|
1194
|
-
- iOS 15.0+
|
|
1195
|
-
- Expo SDK 50+
|
|
1196
|
-
- React Native 0.72+
|
|
1197
|
-
|
|
1198
337
|
## Platform Support
|
|
1199
338
|
|
|
1200
339
|
- โ
iOS (fully supported)
|
|
1201
340
|
- โณ Android (coming soon)
|
|
1202
341
|
|
|
1203
|
-
## Error Handling
|
|
1204
|
-
|
|
1205
|
-
All errors include descriptive codes and messages:
|
|
1206
|
-
|
|
1207
|
-
```typescript
|
|
1208
|
-
try {
|
|
1209
|
-
await TwoStepVideo.exportVideo(options);
|
|
1210
|
-
} catch (error: any) {
|
|
1211
|
-
switch (error.code) {
|
|
1212
|
-
case 'EXPORT_FAILED':
|
|
1213
|
-
// Handle export failure
|
|
1214
|
-
break;
|
|
1215
|
-
case 'ASSET_NOT_FOUND':
|
|
1216
|
-
// Asset was released too early
|
|
1217
|
-
break;
|
|
1218
|
-
default:
|
|
1219
|
-
console.error('Unknown error:', error);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
```
|
|
1223
|
-
|
|
1224
|
-
## Performance Tips
|
|
1225
|
-
|
|
1226
|
-
1. **Release resources promptly** after use
|
|
1227
|
-
2. **Use `releaseAll()`** when unmounting components
|
|
1228
|
-
3. **Validate URIs** before loading with `validateVideoUri()`
|
|
1229
|
-
4. **Clean up temp files** after moving/uploading
|
|
1230
|
-
|
|
1231
|
-
## Contributing
|
|
1232
|
-
|
|
1233
|
-
Contributions welcome! Please see [CONTRIBUTING](./docs/CONTRIBUTING.md).
|
|
1234
|
-
|
|
1235
342
|
## License
|
|
1236
343
|
|
|
1237
344
|
MIT ยฉ Richard Guo
|
|
1238
345
|
|
|
1239
|
-
##
|
|
1240
|
-
|
|
1241
|
-
- ๐ง Email: richardg7890@gmail.com
|
|
1242
|
-
- ๐ Issues: [GitHub Issues](https://github.com/rguo123/twostep-video/issues)
|
|
1243
|
-
- ๐ Docs: [Full Documentation](./docs/)
|
|
1244
|
-
|
|
1245
|
-
## Acknowledgments
|
|
346
|
+
## Links
|
|
1246
347
|
|
|
1247
|
-
|
|
348
|
+
- [GitHub Repository](https://github.com/rguo123/twostep-video)
|
|
349
|
+
- [Issue Tracker](https://github.com/rguo123/twostep-video/issues)
|