@movementinfra/expo-twostep-video 0.1.12 โ 0.1.14
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 +197 -1026
- 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/VideoScrubber.d.ts.map +1 -1
- package/build/VideoScrubber.js +4 -2
- package/build/VideoScrubber.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,328 @@ 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
58
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
105
|
-
return (
|
|
106
|
-
<View>
|
|
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
71
|
|
|
149
|
-
|
|
150
|
-
Load a video from the Photos library.
|
|
151
|
-
|
|
152
|
-
```typescript
|
|
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
|
});
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
### Video Player View
|
|
373
|
-
|
|
374
|
-
#### `TwoStepVideoView`
|
|
375
|
-
A native video player component for playing assets and compositions with full playback control.
|
|
376
210
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
function VideoPlayer({ compositionId }: { compositionId: string }) {
|
|
383
|
-
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
384
|
-
const [status, setStatus] = useState<string>('ready');
|
|
385
|
-
const [progress, setProgress] = useState(0);
|
|
386
|
-
|
|
387
|
-
return (
|
|
388
|
-
<View style={{ flex: 1 }}>
|
|
389
|
-
<TwoStepVideoView
|
|
390
|
-
ref={playerRef}
|
|
391
|
-
compositionId={compositionId}
|
|
392
|
-
loop={false}
|
|
393
|
-
onPlaybackStatusChange={(e) => setStatus(e.nativeEvent.status)}
|
|
394
|
-
onProgress={(e) => setProgress(e.nativeEvent.progress)}
|
|
395
|
-
onEnd={() => console.log('Video ended')}
|
|
396
|
-
onError={(e) => console.error(e.nativeEvent.error)}
|
|
397
|
-
style={{ width: '100%', height: 300 }}
|
|
398
|
-
/>
|
|
399
|
-
|
|
400
|
-
<Text>Status: {status} | Progress: {Math.round(progress * 100)}%</Text>
|
|
401
|
-
|
|
402
|
-
<View style={{ flexDirection: 'row', gap: 10 }}>
|
|
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>
|
|
408
|
-
</View>
|
|
409
|
-
);
|
|
410
|
-
}
|
|
211
|
+
// Track progress
|
|
212
|
+
const subscription = TwoStepVideo.addExportProgressListener((event) => {
|
|
213
|
+
console.log(`${Math.round(event.progress * 100)}%`);
|
|
214
|
+
});
|
|
215
|
+
// Later: subscription.remove();
|
|
411
216
|
```
|
|
412
217
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
| Prop | Type | Description |
|
|
416
|
-
|------|------|-------------|
|
|
417
|
-
| `compositionId` | `string` | ID from `mirrorVideo`, `trimVideo`, `loopSegment`, etc. |
|
|
418
|
-
| `assetId` | `string` | ID from `loadAsset` (for playing without transformations) |
|
|
419
|
-
| `loop` | `boolean` | Enable continuous looping (default: `false`) |
|
|
420
|
-
| `onPlaybackStatusChange` | `function` | Called when status changes (`ready`, `playing`, `paused`, `ended`, `seeked`) |
|
|
421
|
-
| `onProgress` | `function` | Called periodically with `{ currentTime, duration, progress }` |
|
|
422
|
-
| `onEnd` | `function` | Called when video ends (not called if `loop` is true) |
|
|
423
|
-
| `onError` | `function` | Called on playback error |
|
|
424
|
-
| `onPanZoomChange` | `function` | Called when user pinches/pans with `{ panX, panY, zoomLevel }` |
|
|
425
|
-
|
|
426
|
-
#### Player Methods (via ref)
|
|
218
|
+
### Memory Management
|
|
427
219
|
|
|
428
220
|
```typescript
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// Pause playback
|
|
435
|
-
await playerRef.current?.pause();
|
|
436
|
-
|
|
437
|
-
// Seek to specific time (in seconds)
|
|
438
|
-
await playerRef.current?.seek(10.5);
|
|
439
|
-
|
|
440
|
-
// Restart from beginning
|
|
441
|
-
await playerRef.current?.replay();
|
|
221
|
+
TwoStepVideo.releaseAsset(asset.id);
|
|
222
|
+
TwoStepVideo.releaseComposition(composition.id);
|
|
223
|
+
TwoStepVideo.releaseAll(); // Release everything
|
|
224
|
+
TwoStepVideo.cleanupFile(result.uri); // Delete temp file
|
|
442
225
|
```
|
|
443
226
|
|
|
444
|
-
|
|
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
|
|
227
|
+
## Video Scrubber Component
|
|
449
228
|
|
|
450
|
-
|
|
451
|
-
|---------|--------|
|
|
452
|
-
| Pinch (2 fingers) | Zoom in/out (1x to 5x) |
|
|
453
|
-
| Drag (2 fingers) | Pan around when zoomed in |
|
|
229
|
+
### VideoScrubber
|
|
454
230
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
Listen for pan/zoom state changes with the `onPanZoomChange` prop:
|
|
231
|
+
A native-style video scrubber/trimmer with thumbnail strip, draggable trim handles, and a draggable playhead for frame-accurate seeking:
|
|
458
232
|
|
|
459
233
|
```tsx
|
|
460
|
-
import {
|
|
234
|
+
import { VideoScrubber, VideoScrubberRef } from 'expo-twostep-video';
|
|
461
235
|
|
|
462
|
-
function
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
});
|
|
236
|
+
function VideoEditor({ asset }: { asset: VideoAsset }) {
|
|
237
|
+
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
238
|
+
const [trimStart, setTrimStart] = useState(0);
|
|
239
|
+
const [trimEnd, setTrimEnd] = useState(asset.duration);
|
|
240
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
468
241
|
|
|
469
242
|
return (
|
|
470
243
|
<View>
|
|
471
244
|
<TwoStepVideoView
|
|
245
|
+
ref={playerRef}
|
|
472
246
|
assetId={asset.id}
|
|
473
|
-
|
|
474
|
-
style={{ width: '100%', height: 300 }}
|
|
247
|
+
onProgress={(e) => setCurrentTime(e.nativeEvent.currentTime)}
|
|
475
248
|
/>
|
|
476
249
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
250
|
+
<VideoScrubber
|
|
251
|
+
assetId={asset.id}
|
|
252
|
+
duration={asset.duration}
|
|
253
|
+
currentTime={currentTime}
|
|
254
|
+
startTime={trimStart}
|
|
255
|
+
endTime={trimEnd}
|
|
256
|
+
onStartTimeChange={setTrimStart}
|
|
257
|
+
onEndTimeChange={setTrimEnd}
|
|
258
|
+
onScrub={(time) => playerRef.current?.seek(time)}
|
|
259
|
+
onScrubbing={(time) => playerRef.current?.seek(time)}
|
|
260
|
+
/>
|
|
483
261
|
</View>
|
|
484
262
|
);
|
|
485
263
|
}
|
|
486
264
|
```
|
|
487
265
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
Control pan/zoom programmatically using the player ref:
|
|
266
|
+
**Props:**
|
|
491
267
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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:
|
|
268
|
+
| Prop | Type | Description |
|
|
269
|
+
|------|------|-------------|
|
|
270
|
+
| `assetId` | `string` | Asset ID for thumbnail generation |
|
|
271
|
+
| `duration` | `number` | Total video duration in seconds |
|
|
272
|
+
| `currentTime` | `number` | Current playback position (for playhead) |
|
|
273
|
+
| `startTime` | `number` | Selected start time in seconds |
|
|
274
|
+
| `endTime` | `number` | Selected end time in seconds |
|
|
275
|
+
| `thumbnailCount` | `number` | Number of thumbnails (default: 10) |
|
|
276
|
+
| `thumbnailHeight` | `number` | Height of thumbnail strip (default: 50) |
|
|
277
|
+
| `onStartTimeChange` | `function` | Called when start handle moves |
|
|
278
|
+
| `onEndTimeChange` | `function` | Called when end handle moves |
|
|
279
|
+
| `onScrub` | `function` | Called when playhead is tapped or dragged |
|
|
280
|
+
| `onScrubbing` | `function` | Called continuously during any drag |
|
|
281
|
+
| `onScrubEnd` | `function` | Called when drag ends |
|
|
282
|
+
| `minDuration` | `number` | Minimum selection duration (default: 0.5) |
|
|
283
|
+
| `showPlayhead` | `boolean` | Show playhead indicator (default: true) |
|
|
284
|
+
| `handleWidth` | `number` | Handle width in pixels (default: 20) |
|
|
285
|
+
| `theme` | `object` | Custom colors for handles, playhead, etc. |
|
|
286
|
+
|
|
287
|
+
**Features:**
|
|
288
|
+
- Drag the **yellow handles** to adjust trim start/end times
|
|
289
|
+
- Drag the **white playhead** to scrub through the video
|
|
290
|
+
- Tap anywhere in the selection to seek to that position
|
|
291
|
+
- All drag operations call `onScrubbing` for smooth video preview
|
|
292
|
+
|
|
293
|
+
## Video Player Component
|
|
294
|
+
|
|
295
|
+
### TwoStepVideoView
|
|
296
|
+
|
|
297
|
+
Native video player with pan/zoom gesture support:
|
|
535
298
|
|
|
536
299
|
```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';
|
|
300
|
+
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
|
|
545
301
|
|
|
546
|
-
function
|
|
302
|
+
function VideoPlayer({ compositionId }: { compositionId: string }) {
|
|
547
303
|
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
304
|
|
|
584
305
|
return (
|
|
585
|
-
<View
|
|
586
|
-
{/* Video Preview with Gestures */}
|
|
306
|
+
<View>
|
|
587
307
|
<TwoStepVideoView
|
|
588
308
|
ref={playerRef}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
309
|
+
compositionId={compositionId}
|
|
310
|
+
loop={false}
|
|
311
|
+
onPlaybackStatusChange={(e) => console.log(e.nativeEvent.status)}
|
|
312
|
+
onProgress={(e) => console.log(e.nativeEvent.progress)}
|
|
313
|
+
onPanZoomChange={(e) => console.log(e.nativeEvent.zoomLevel)}
|
|
314
|
+
style={{ width: '100%', height: 300 }}
|
|
592
315
|
/>
|
|
593
316
|
|
|
594
|
-
{
|
|
595
|
-
<
|
|
596
|
-
|
|
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>
|
|
317
|
+
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
318
|
+
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
319
|
+
<Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
|
|
621
320
|
</View>
|
|
622
321
|
);
|
|
623
322
|
}
|
|
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
323
|
```
|
|
635
324
|
|
|
636
|
-
|
|
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
|
|
325
|
+
**Props:**
|
|
642
326
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
327
|
+
| Prop | Type | Description |
|
|
328
|
+
|------|------|-------------|
|
|
329
|
+
| `compositionId` | `string` | ID from trimVideo, mirrorVideo, etc. |
|
|
330
|
+
| `assetId` | `string` | ID from loadAsset (for raw playback) |
|
|
331
|
+
| `loop` | `boolean` | Enable continuous looping |
|
|
332
|
+
| `minZoom` | `number` | Minimum zoom level (default: 1.0) |
|
|
333
|
+
| `maxZoom` | `number` | Maximum zoom level (default: 5.0) |
|
|
334
|
+
| `onPlaybackStatusChange` | `function` | Status: ready, playing, paused, ended |
|
|
335
|
+
| `onProgress` | `function` | Progress: currentTime, duration, progress |
|
|
336
|
+
| `onPanZoomChange` | `function` | Pan/zoom: panX, panY, zoomLevel |
|
|
337
|
+
| `onEnd` | `function` | Called when playback ends |
|
|
338
|
+
| `onError` | `function` | Called on error |
|
|
339
|
+
|
|
340
|
+
**Ref Methods:**
|
|
647
341
|
|
|
648
342
|
```typescript
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
343
|
+
await playerRef.current?.play();
|
|
344
|
+
await playerRef.current?.pause();
|
|
345
|
+
await playerRef.current?.seek(10.5);
|
|
346
|
+
await playerRef.current?.replay();
|
|
652
347
|
|
|
653
|
-
//
|
|
654
|
-
|
|
348
|
+
// Pan/zoom control
|
|
349
|
+
const state = await playerRef.current?.getPanZoomState();
|
|
350
|
+
await playerRef.current?.setPanZoomState({ zoomLevel: 2.0 });
|
|
351
|
+
await playerRef.current?.resetPanZoom();
|
|
655
352
|
```
|
|
656
353
|
|
|
657
|
-
###
|
|
354
|
+
### TwoStepPlayerControllerView
|
|
658
355
|
|
|
659
|
-
|
|
660
|
-
// Release assets when done
|
|
661
|
-
TwoStepVideo.releaseAsset(asset.id);
|
|
662
|
-
TwoStepVideo.releaseComposition(composition.id);
|
|
356
|
+
Native iOS player with system controls (AirPlay, PiP, fullscreen):
|
|
663
357
|
|
|
664
|
-
|
|
665
|
-
|
|
358
|
+
```tsx
|
|
359
|
+
import { TwoStepPlayerControllerView } from 'expo-twostep-video';
|
|
666
360
|
|
|
667
|
-
|
|
668
|
-
|
|
361
|
+
<TwoStepPlayerControllerView
|
|
362
|
+
assetId={asset.id}
|
|
363
|
+
showsPlaybackControls={true}
|
|
364
|
+
style={{ width: '100%', height: 300 }}
|
|
365
|
+
/>
|
|
669
366
|
```
|
|
670
367
|
|
|
671
368
|
## Constants
|
|
@@ -673,575 +370,49 @@ TwoStepVideo.cleanupFile(tempFileUri);
|
|
|
673
370
|
### Quality Presets
|
|
674
371
|
|
|
675
372
|
```typescript
|
|
676
|
-
TwoStepVideo.Quality.LOW
|
|
677
|
-
TwoStepVideo.Quality.MEDIUM
|
|
678
|
-
TwoStepVideo.Quality.HIGH
|
|
679
|
-
TwoStepVideo.Quality.HIGHEST
|
|
373
|
+
TwoStepVideo.Quality.LOW // Fast, small files
|
|
374
|
+
TwoStepVideo.Quality.MEDIUM // Good for web/social
|
|
375
|
+
TwoStepVideo.Quality.HIGH // Recommended default
|
|
376
|
+
TwoStepVideo.Quality.HIGHEST // Archival quality
|
|
680
377
|
```
|
|
681
378
|
|
|
682
379
|
### Mirror Axis
|
|
683
380
|
|
|
684
381
|
```typescript
|
|
685
|
-
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right
|
|
382
|
+
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right
|
|
686
383
|
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
|
-
}
|
|
384
|
+
TwoStepVideo.Mirror.BOTH // Flip both
|
|
803
385
|
```
|
|
804
386
|
|
|
805
|
-
##
|
|
806
|
-
|
|
807
|
-
### Create a Highlight Reel
|
|
387
|
+
## TypeScript Types
|
|
808
388
|
|
|
809
389
|
```typescript
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
);
|
|
1119
|
-
```
|
|
1120
|
-
|
|
1121
|
-
### With Progress Bar
|
|
1122
|
-
|
|
1123
|
-
```typescript
|
|
1124
|
-
function VideoExporter({ videoUri }) {
|
|
1125
|
-
const progress = TwoStepVideo.useExportProgress();
|
|
1126
|
-
|
|
1127
|
-
return (
|
|
1128
|
-
<View>
|
|
1129
|
-
<ProgressBar progress={progress} />
|
|
1130
|
-
<Text>{Math.round(progress * 100)}%</Text>
|
|
1131
|
-
</View>
|
|
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
|
|
390
|
+
import type {
|
|
391
|
+
VideoAsset,
|
|
392
|
+
VideoComposition,
|
|
393
|
+
ExportResult,
|
|
394
|
+
LoopResult,
|
|
395
|
+
TwoStepVideoViewRef,
|
|
396
|
+
TwoStepVideoViewProps,
|
|
397
|
+
PanZoomState,
|
|
398
|
+
MirrorAxis,
|
|
399
|
+
VideoQuality,
|
|
400
|
+
VideoScrubberProps,
|
|
401
|
+
VideoScrubberRef,
|
|
402
|
+
VideoScrubberTheme,
|
|
403
|
+
} from 'expo-twostep-video';
|
|
1184
404
|
```
|
|
1185
405
|
|
|
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
406
|
## Platform Support
|
|
1199
407
|
|
|
1200
408
|
- โ
iOS (fully supported)
|
|
1201
409
|
- โณ Android (coming soon)
|
|
1202
410
|
|
|
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
411
|
## License
|
|
1236
412
|
|
|
1237
413
|
MIT ยฉ Richard Guo
|
|
1238
414
|
|
|
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
|
|
415
|
+
## Links
|
|
1246
416
|
|
|
1247
|
-
|
|
417
|
+
- [GitHub Repository](https://github.com/rguo123/twostep-video)
|
|
418
|
+
- [Issue Tracker](https://github.com/rguo123/twostep-video/issues)
|