@movementinfra/expo-twostep-video 0.1.0 โ 0.1.2
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 +611 -5
- package/expo-module.config.json +2 -6
- package/ios/ExpoTwostepVideo.podspec +5 -6
- package/package.json +5 -6
package/README.md
CHANGED
|
@@ -9,8 +9,13 @@ Professional video editing for React Native, powered by native AVFoundation.
|
|
|
9
9
|
|
|
10
10
|
- ๐ฌ **Load videos** from file system or Photos library
|
|
11
11
|
- โ๏ธ **Trim videos** with frame-accurate precision
|
|
12
|
+
- ๐ช **Mirror videos** horizontally, vertically, or both
|
|
13
|
+
- โฑ๏ธ **Speed adjustment** - slow motion (0.25x) to fast forward (4x)
|
|
14
|
+
- ๐ **Loop segments** - repeat video sections for perfect loops
|
|
15
|
+
- ๐๏ธ **Combined transformations** - trim + mirror + speed in one operation
|
|
12
16
|
- ๐๏ธ **Create multi-segment compositions** (highlight reels)
|
|
13
17
|
- ๐ธ **Generate thumbnails** at any timestamp
|
|
18
|
+
- ๐ฅ **Native video player** with playback controls
|
|
14
19
|
- ๐พ **Export** with customizable quality settings
|
|
15
20
|
- ๐ **Real-time progress tracking** during exports
|
|
16
21
|
- ๐งน **Automatic cleanup** of partial/temp files
|
|
@@ -108,6 +113,26 @@ function VideoEditor() {
|
|
|
108
113
|
|
|
109
114
|
## API Reference
|
|
110
115
|
|
|
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
|
+
| `generateThumbnails({ assetId, times, size? })` | Extract frames | `string[]` (base64) |
|
|
130
|
+
| `exportVideo({ compositionId, quality? })` | Export composition | `ExportResult` |
|
|
131
|
+
| `exportAsset({ assetId, quality? })` | Export asset directly | `ExportResult` |
|
|
132
|
+
| `releaseAsset(id)` | Free memory | `void` |
|
|
133
|
+
| `releaseComposition(id)` | Free memory | `void` |
|
|
134
|
+
| `releaseAll()` | Free all memory | `void` |
|
|
135
|
+
|
|
111
136
|
### Loading Videos
|
|
112
137
|
|
|
113
138
|
#### `loadAsset(options)`
|
|
@@ -177,6 +202,149 @@ const thumbnails = await TwoStepVideo.generateThumbnails({
|
|
|
177
202
|
<Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />
|
|
178
203
|
```
|
|
179
204
|
|
|
205
|
+
### Video Mirroring
|
|
206
|
+
|
|
207
|
+
#### `mirrorVideo(options)`
|
|
208
|
+
Mirror (flip) a video horizontally, vertically, or both. Perfect for selfie videos that need to be flipped.
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// Mirror horizontally (flip left-right) - common for selfie videos
|
|
212
|
+
const mirrored = await TwoStepVideo.mirrorVideo({
|
|
213
|
+
assetId: asset.id,
|
|
214
|
+
axis: 'horizontal'
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Mirror vertically (flip top-bottom)
|
|
218
|
+
const flipped = await TwoStepVideo.mirrorVideo({
|
|
219
|
+
assetId: asset.id,
|
|
220
|
+
axis: 'vertical'
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Mirror both axes (180ยฐ rotation effect)
|
|
224
|
+
const both = await TwoStepVideo.mirrorVideo({
|
|
225
|
+
assetId: asset.id,
|
|
226
|
+
axis: 'both'
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Mirror only a specific segment (5s to 10s)
|
|
230
|
+
const partialMirror = await TwoStepVideo.mirrorVideo({
|
|
231
|
+
assetId: asset.id,
|
|
232
|
+
axis: 'horizontal',
|
|
233
|
+
startTime: 5,
|
|
234
|
+
endTime: 10
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Speed Adjustment
|
|
239
|
+
|
|
240
|
+
#### `adjustSpeed(options)`
|
|
241
|
+
Change the playback speed of a video. Supports slow motion (< 1.0) and fast forward (> 1.0).
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// Slow motion (0.5x = 2x slower, video becomes twice as long)
|
|
245
|
+
const slowMo = await TwoStepVideo.adjustSpeed({
|
|
246
|
+
assetId: asset.id,
|
|
247
|
+
speed: 0.5
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Very slow motion (0.25x = 4x slower)
|
|
251
|
+
const verySlow = await TwoStepVideo.adjustSpeed({
|
|
252
|
+
assetId: asset.id,
|
|
253
|
+
speed: 0.25
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Fast forward (2x speed, video becomes half as long)
|
|
257
|
+
const fastForward = await TwoStepVideo.adjustSpeed({
|
|
258
|
+
assetId: asset.id,
|
|
259
|
+
speed: 2.0
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Timelapse effect (4x speed)
|
|
263
|
+
const timelapse = await TwoStepVideo.adjustSpeed({
|
|
264
|
+
assetId: asset.id,
|
|
265
|
+
speed: 4.0
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Speed up only a specific segment (10s to 30s becomes a quick timelapse)
|
|
269
|
+
const partialSpeed = await TwoStepVideo.adjustSpeed({
|
|
270
|
+
assetId: asset.id,
|
|
271
|
+
speed: 4.0,
|
|
272
|
+
startTime: 10,
|
|
273
|
+
endTime: 30
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Video Looping
|
|
278
|
+
|
|
279
|
+
#### `loopSegment(options)`
|
|
280
|
+
Loop a segment of video multiple times. Great for creating perfect loops for social media or extending short clips.
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
// Loop a 2-second segment 3 times (plays 4 times total = 8 seconds)
|
|
284
|
+
const looped = await TwoStepVideo.loopSegment({
|
|
285
|
+
assetId: asset.id,
|
|
286
|
+
startTime: 5,
|
|
287
|
+
endTime: 7,
|
|
288
|
+
loopCount: 3 // Repeats 3 times after first play
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
console.log(`Duration: ${looped.duration}s`); // 8 seconds
|
|
292
|
+
console.log(`Total plays: ${looped.totalPlays}`); // 4 times
|
|
293
|
+
|
|
294
|
+
// Create a 15-second loop from a 3-second clip for Instagram/TikTok
|
|
295
|
+
const socialLoop = await TwoStepVideo.loopSegment({
|
|
296
|
+
assetId: asset.id,
|
|
297
|
+
startTime: 0,
|
|
298
|
+
endTime: 3,
|
|
299
|
+
loopCount: 4 // 3s * 5 plays = 15 seconds
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Loop the best moment of a video
|
|
303
|
+
const bestMoment = await TwoStepVideo.loopSegment({
|
|
304
|
+
assetId: asset.id,
|
|
305
|
+
startTime: 12.5, // Start at 12.5 seconds
|
|
306
|
+
endTime: 14.0, // 1.5 second clip
|
|
307
|
+
loopCount: 9 // 1.5s * 10 plays = 15 seconds
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Combined Transformations
|
|
312
|
+
|
|
313
|
+
#### `transformVideo(options)`
|
|
314
|
+
Apply multiple transformations in a single operation: trim, mirror, and speed adjustment combined.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// Mirror and slow down (selfie video correction + slow-mo effect)
|
|
318
|
+
const transformed = await TwoStepVideo.transformVideo({
|
|
319
|
+
assetId: asset.id,
|
|
320
|
+
speed: 0.5,
|
|
321
|
+
mirrorAxis: 'horizontal'
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Just mirror (speed defaults to 1.0)
|
|
325
|
+
const mirrored = await TwoStepVideo.transformVideo({
|
|
326
|
+
assetId: asset.id,
|
|
327
|
+
mirrorAxis: 'both'
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Trim + mirror + speed all at once
|
|
331
|
+
const fullTransform = await TwoStepVideo.transformVideo({
|
|
332
|
+
assetId: asset.id,
|
|
333
|
+
speed: 2.0,
|
|
334
|
+
mirrorAxis: 'horizontal',
|
|
335
|
+
startTime: 0,
|
|
336
|
+
endTime: 10 // Take first 10 seconds, mirror it, speed it up
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Speed up without mirroring (just provide speed)
|
|
340
|
+
const fastVersion = await TwoStepVideo.transformVideo({
|
|
341
|
+
assetId: asset.id,
|
|
342
|
+
speed: 1.5,
|
|
343
|
+
startTime: 5,
|
|
344
|
+
endTime: 15
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
180
348
|
### Exporting
|
|
181
349
|
|
|
182
350
|
#### `exportVideo(options)`
|
|
@@ -200,6 +368,77 @@ const result = await TwoStepVideo.exportAsset({
|
|
|
200
368
|
});
|
|
201
369
|
```
|
|
202
370
|
|
|
371
|
+
### Video Player View
|
|
372
|
+
|
|
373
|
+
#### `TwoStepVideoView`
|
|
374
|
+
A native video player component for playing assets and compositions with full playback control.
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
|
|
378
|
+
import { useRef, useState } from 'react';
|
|
379
|
+
import { View, Button, Text } from 'react-native';
|
|
380
|
+
|
|
381
|
+
function VideoPlayer({ compositionId }: { compositionId: string }) {
|
|
382
|
+
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
383
|
+
const [status, setStatus] = useState<string>('ready');
|
|
384
|
+
const [progress, setProgress] = useState(0);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<View style={{ flex: 1 }}>
|
|
388
|
+
<TwoStepVideoView
|
|
389
|
+
ref={playerRef}
|
|
390
|
+
compositionId={compositionId}
|
|
391
|
+
loop={false}
|
|
392
|
+
onPlaybackStatusChange={(e) => setStatus(e.nativeEvent.status)}
|
|
393
|
+
onProgress={(e) => setProgress(e.nativeEvent.progress)}
|
|
394
|
+
onEnd={() => console.log('Video ended')}
|
|
395
|
+
onError={(e) => console.error(e.nativeEvent.error)}
|
|
396
|
+
style={{ width: '100%', height: 300 }}
|
|
397
|
+
/>
|
|
398
|
+
|
|
399
|
+
<Text>Status: {status} | Progress: {Math.round(progress * 100)}%</Text>
|
|
400
|
+
|
|
401
|
+
<View style={{ flexDirection: 'row', gap: 10 }}>
|
|
402
|
+
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
403
|
+
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
404
|
+
<Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
|
|
405
|
+
<Button title="Replay" onPress={() => playerRef.current?.replay()} />
|
|
406
|
+
</View>
|
|
407
|
+
</View>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
#### Player Props
|
|
413
|
+
|
|
414
|
+
| Prop | Type | Description |
|
|
415
|
+
|------|------|-------------|
|
|
416
|
+
| `compositionId` | `string` | ID from `mirrorVideo`, `trimVideo`, `loopSegment`, etc. |
|
|
417
|
+
| `assetId` | `string` | ID from `loadAsset` (for playing without transformations) |
|
|
418
|
+
| `loop` | `boolean` | Enable continuous looping (default: `false`) |
|
|
419
|
+
| `onPlaybackStatusChange` | `function` | Called when status changes (`ready`, `playing`, `paused`, `ended`, `seeked`) |
|
|
420
|
+
| `onProgress` | `function` | Called periodically with `{ currentTime, duration, progress }` |
|
|
421
|
+
| `onEnd` | `function` | Called when video ends (not called if `loop` is true) |
|
|
422
|
+
| `onError` | `function` | Called on playback error |
|
|
423
|
+
|
|
424
|
+
#### Player Methods (via ref)
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
428
|
+
|
|
429
|
+
// Start playback
|
|
430
|
+
await playerRef.current?.play();
|
|
431
|
+
|
|
432
|
+
// Pause playback
|
|
433
|
+
await playerRef.current?.pause();
|
|
434
|
+
|
|
435
|
+
// Seek to specific time (in seconds)
|
|
436
|
+
await playerRef.current?.seek(10.5);
|
|
437
|
+
|
|
438
|
+
// Restart from beginning
|
|
439
|
+
await playerRef.current?.replay();
|
|
440
|
+
```
|
|
441
|
+
|
|
203
442
|
### Events
|
|
204
443
|
|
|
205
444
|
#### `addExportProgressListener(callback)`
|
|
@@ -228,20 +467,41 @@ TwoStepVideo.releaseAll();
|
|
|
228
467
|
TwoStepVideo.cleanupFile(tempFileUri);
|
|
229
468
|
```
|
|
230
469
|
|
|
231
|
-
##
|
|
470
|
+
## Constants
|
|
471
|
+
|
|
472
|
+
### Quality Presets
|
|
232
473
|
|
|
233
474
|
```typescript
|
|
234
|
-
TwoStepVideo.Quality.LOW // ~0.1 bits per pixel
|
|
235
|
-
TwoStepVideo.Quality.MEDIUM // ~0.2 bits per pixel
|
|
236
|
-
TwoStepVideo.Quality.HIGH // ~0.4 bits per pixel (recommended)
|
|
237
|
-
TwoStepVideo.Quality.HIGHEST // ~0.8 bits per pixel
|
|
475
|
+
TwoStepVideo.Quality.LOW // ~0.1 bits per pixel - fast, small files
|
|
476
|
+
TwoStepVideo.Quality.MEDIUM // ~0.2 bits per pixel - good for web/social
|
|
477
|
+
TwoStepVideo.Quality.HIGH // ~0.4 bits per pixel (recommended default)
|
|
478
|
+
TwoStepVideo.Quality.HIGHEST // ~0.8 bits per pixel - archival quality
|
|
238
479
|
```
|
|
239
480
|
|
|
481
|
+
### Mirror Axis
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right (selfie correction)
|
|
485
|
+
TwoStepVideo.Mirror.VERTICAL // Flip top-bottom
|
|
486
|
+
TwoStepVideo.Mirror.BOTH // Flip both axes (180ยฐ rotation effect)
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Speed Ranges
|
|
490
|
+
|
|
491
|
+
| Speed | Effect | Duration Change |
|
|
492
|
+
|-------|--------|-----------------|
|
|
493
|
+
| 0.25 | Very slow motion | 4x longer |
|
|
494
|
+
| 0.5 | Slow motion | 2x longer |
|
|
495
|
+
| 1.0 | Normal speed | No change |
|
|
496
|
+
| 2.0 | Fast forward | 2x shorter |
|
|
497
|
+
| 4.0 | Timelapse | 4x shorter |
|
|
498
|
+
|
|
240
499
|
## TypeScript Support
|
|
241
500
|
|
|
242
501
|
Full TypeScript definitions included:
|
|
243
502
|
|
|
244
503
|
```typescript
|
|
504
|
+
// Core types
|
|
245
505
|
interface VideoAsset {
|
|
246
506
|
id: string;
|
|
247
507
|
duration: number;
|
|
@@ -260,6 +520,65 @@ interface ExportResult {
|
|
|
260
520
|
uri: string;
|
|
261
521
|
path: string;
|
|
262
522
|
}
|
|
523
|
+
|
|
524
|
+
// Transformation types
|
|
525
|
+
type MirrorAxis = 'horizontal' | 'vertical' | 'both';
|
|
526
|
+
type VideoQuality = 'low' | 'medium' | 'high' | 'highest';
|
|
527
|
+
|
|
528
|
+
interface MirrorVideoOptions {
|
|
529
|
+
assetId: string;
|
|
530
|
+
axis: MirrorAxis;
|
|
531
|
+
startTime?: number; // Optional: mirror only a segment
|
|
532
|
+
endTime?: number;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
interface AdjustSpeedOptions {
|
|
536
|
+
assetId: string;
|
|
537
|
+
speed: number; // 0.25 to 4.0
|
|
538
|
+
startTime?: number;
|
|
539
|
+
endTime?: number;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interface LoopSegmentOptions {
|
|
543
|
+
assetId: string;
|
|
544
|
+
startTime: number;
|
|
545
|
+
endTime: number;
|
|
546
|
+
loopCount: number; // Repeats after first play
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
interface LoopResult {
|
|
550
|
+
id: string;
|
|
551
|
+
duration: number;
|
|
552
|
+
loopCount: number;
|
|
553
|
+
totalPlays: number; // loopCount + 1
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
interface TransformVideoOptions {
|
|
557
|
+
assetId: string;
|
|
558
|
+
speed?: number;
|
|
559
|
+
mirrorAxis?: MirrorAxis;
|
|
560
|
+
startTime?: number;
|
|
561
|
+
endTime?: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Player types
|
|
565
|
+
interface TwoStepVideoViewProps {
|
|
566
|
+
compositionId?: string;
|
|
567
|
+
assetId?: string;
|
|
568
|
+
loop?: boolean;
|
|
569
|
+
onPlaybackStatusChange?: (event: { nativeEvent: PlaybackStatusEvent }) => void;
|
|
570
|
+
onProgress?: (event: { nativeEvent: ProgressEvent }) => void;
|
|
571
|
+
onEnd?: (event: { nativeEvent: {} }) => void;
|
|
572
|
+
onError?: (event: { nativeEvent: ErrorEvent }) => void;
|
|
573
|
+
style?: ViewStyle;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
interface TwoStepVideoViewRef {
|
|
577
|
+
play: () => Promise<void>;
|
|
578
|
+
pause: () => Promise<void>;
|
|
579
|
+
seek: (time: number) => Promise<void>;
|
|
580
|
+
replay: () => Promise<void>;
|
|
581
|
+
}
|
|
263
582
|
```
|
|
264
583
|
|
|
265
584
|
## Advanced Usage
|
|
@@ -291,6 +610,293 @@ async function createHighlightReel(videoUri: string) {
|
|
|
291
610
|
}
|
|
292
611
|
```
|
|
293
612
|
|
|
613
|
+
### Selfie Video Mirror & Loop
|
|
614
|
+
|
|
615
|
+
A complete example for processing selfie videos with mirror correction and perfect loops:
|
|
616
|
+
|
|
617
|
+
```tsx
|
|
618
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
619
|
+
import { View, Button, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
|
620
|
+
import * as TwoStepVideo from 'expo-twostep-video';
|
|
621
|
+
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
|
|
622
|
+
|
|
623
|
+
interface Props {
|
|
624
|
+
videoUri: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function SelfieVideoEditor({ videoUri }: Props) {
|
|
628
|
+
const playerRef = useRef<TwoStepVideoViewRef>(null);
|
|
629
|
+
const [asset, setAsset] = useState<TwoStepVideo.VideoAsset | null>(null);
|
|
630
|
+
const [composition, setComposition] = useState<TwoStepVideo.VideoComposition | null>(null);
|
|
631
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
632
|
+
const [exportProgress, setExportProgress] = useState(0);
|
|
633
|
+
|
|
634
|
+
// Load the video asset
|
|
635
|
+
useEffect(() => {
|
|
636
|
+
async function loadVideo() {
|
|
637
|
+
const loaded = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
638
|
+
setAsset(loaded);
|
|
639
|
+
}
|
|
640
|
+
loadVideo();
|
|
641
|
+
|
|
642
|
+
return () => {
|
|
643
|
+
// Cleanup on unmount
|
|
644
|
+
TwoStepVideo.releaseAll();
|
|
645
|
+
};
|
|
646
|
+
}, [videoUri]);
|
|
647
|
+
|
|
648
|
+
// Mirror the selfie video (fix the mirror effect from front camera)
|
|
649
|
+
const handleMirror = async () => {
|
|
650
|
+
if (!asset) return;
|
|
651
|
+
setIsProcessing(true);
|
|
652
|
+
|
|
653
|
+
const mirrored = await TwoStepVideo.mirrorVideo({
|
|
654
|
+
assetId: asset.id,
|
|
655
|
+
axis: 'horizontal'
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
setComposition(mirrored);
|
|
659
|
+
setIsProcessing(false);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Create a slow-motion effect
|
|
663
|
+
const handleSlowMo = async () => {
|
|
664
|
+
if (!asset) return;
|
|
665
|
+
setIsProcessing(true);
|
|
666
|
+
|
|
667
|
+
const slowMo = await TwoStepVideo.adjustSpeed({
|
|
668
|
+
assetId: asset.id,
|
|
669
|
+
speed: 0.5 // Half speed
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
setComposition(slowMo);
|
|
673
|
+
setIsProcessing(false);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Mirror + slow motion combined
|
|
677
|
+
const handleMirrorAndSlowMo = async () => {
|
|
678
|
+
if (!asset) return;
|
|
679
|
+
setIsProcessing(true);
|
|
680
|
+
|
|
681
|
+
const transformed = await TwoStepVideo.transformVideo({
|
|
682
|
+
assetId: asset.id,
|
|
683
|
+
speed: 0.5,
|
|
684
|
+
mirrorAxis: 'horizontal'
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
setComposition(transformed);
|
|
688
|
+
setIsProcessing(false);
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Create a perfect loop for social media
|
|
692
|
+
const handleCreateLoop = async () => {
|
|
693
|
+
if (!asset) return;
|
|
694
|
+
setIsProcessing(true);
|
|
695
|
+
|
|
696
|
+
// Loop the first 3 seconds to create 15 seconds of content
|
|
697
|
+
const looped = await TwoStepVideo.loopSegment({
|
|
698
|
+
assetId: asset.id,
|
|
699
|
+
startTime: 0,
|
|
700
|
+
endTime: 3,
|
|
701
|
+
loopCount: 4 // 3s * 5 plays = 15 seconds
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
setComposition(looped);
|
|
705
|
+
setIsProcessing(false);
|
|
706
|
+
console.log(`Created ${looped.totalPlays}-play loop, ${looped.duration}s duration`);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// Export the result
|
|
710
|
+
const handleExport = async () => {
|
|
711
|
+
if (!composition) return;
|
|
712
|
+
|
|
713
|
+
const subscription = TwoStepVideo.addExportProgressListener((event) => {
|
|
714
|
+
setExportProgress(event.progress);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const result = await TwoStepVideo.exportVideo({
|
|
719
|
+
compositionId: composition.id,
|
|
720
|
+
quality: 'high'
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
alert(`Exported to: ${result.uri}`);
|
|
724
|
+
} finally {
|
|
725
|
+
subscription.remove();
|
|
726
|
+
setExportProgress(0);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
if (!asset) {
|
|
731
|
+
return <ActivityIndicator size="large" />;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return (
|
|
735
|
+
<View style={styles.container}>
|
|
736
|
+
{/* Video Player */}
|
|
737
|
+
<TwoStepVideoView
|
|
738
|
+
ref={playerRef}
|
|
739
|
+
compositionId={composition?.id}
|
|
740
|
+
assetId={!composition ? asset.id : undefined}
|
|
741
|
+
loop={true}
|
|
742
|
+
style={styles.video}
|
|
743
|
+
/>
|
|
744
|
+
|
|
745
|
+
{/* Video Info */}
|
|
746
|
+
<Text style={styles.info}>
|
|
747
|
+
Duration: {composition?.duration.toFixed(1) || asset.duration.toFixed(1)}s |
|
|
748
|
+
Size: {asset.width}x{asset.height}
|
|
749
|
+
</Text>
|
|
750
|
+
|
|
751
|
+
{/* Processing Indicator */}
|
|
752
|
+
{isProcessing && <ActivityIndicator size="small" />}
|
|
753
|
+
|
|
754
|
+
{/* Transformation Buttons */}
|
|
755
|
+
<View style={styles.buttons}>
|
|
756
|
+
<Button title="Mirror" onPress={handleMirror} disabled={isProcessing} />
|
|
757
|
+
<Button title="Slow-Mo" onPress={handleSlowMo} disabled={isProcessing} />
|
|
758
|
+
<Button title="Mirror + Slow" onPress={handleMirrorAndSlowMo} disabled={isProcessing} />
|
|
759
|
+
<Button title="Create Loop" onPress={handleCreateLoop} disabled={isProcessing} />
|
|
760
|
+
</View>
|
|
761
|
+
|
|
762
|
+
{/* Playback Controls */}
|
|
763
|
+
<View style={styles.controls}>
|
|
764
|
+
<Button title="Play" onPress={() => playerRef.current?.play()} />
|
|
765
|
+
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
|
|
766
|
+
<Button title="Replay" onPress={() => playerRef.current?.replay()} />
|
|
767
|
+
</View>
|
|
768
|
+
|
|
769
|
+
{/* Export */}
|
|
770
|
+
{composition && (
|
|
771
|
+
<View style={styles.export}>
|
|
772
|
+
<Button title="Export Video" onPress={handleExport} />
|
|
773
|
+
{exportProgress > 0 && (
|
|
774
|
+
<Text>Exporting: {Math.round(exportProgress * 100)}%</Text>
|
|
775
|
+
)}
|
|
776
|
+
</View>
|
|
777
|
+
)}
|
|
778
|
+
</View>
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const styles = StyleSheet.create({
|
|
783
|
+
container: { flex: 1, padding: 16 },
|
|
784
|
+
video: { width: '100%', height: 300, backgroundColor: '#000' },
|
|
785
|
+
info: { textAlign: 'center', marginVertical: 8 },
|
|
786
|
+
buttons: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginVertical: 8 },
|
|
787
|
+
controls: { flexDirection: 'row', justifyContent: 'center', gap: 16 },
|
|
788
|
+
export: { marginTop: 16, alignItems: 'center' },
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
export default SelfieVideoEditor;
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Timestamp-Based Effects
|
|
795
|
+
|
|
796
|
+
Apply different effects to specific timestamps/segments of a video:
|
|
797
|
+
|
|
798
|
+
```typescript
|
|
799
|
+
import * as TwoStepVideo from 'expo-twostep-video';
|
|
800
|
+
|
|
801
|
+
async function applyTimestampEffects(videoUri: string) {
|
|
802
|
+
const asset = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
803
|
+
|
|
804
|
+
// Example: Mirror only the segment from 5s to 10s
|
|
805
|
+
const mirroredSegment = await TwoStepVideo.mirrorVideo({
|
|
806
|
+
assetId: asset.id,
|
|
807
|
+
axis: 'horizontal',
|
|
808
|
+
startTime: 5,
|
|
809
|
+
endTime: 10
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Example: Speed up only a boring middle section (10s to 30s at 4x speed)
|
|
813
|
+
const timelapseMiddle = await TwoStepVideo.adjustSpeed({
|
|
814
|
+
assetId: asset.id,
|
|
815
|
+
speed: 4.0,
|
|
816
|
+
startTime: 10,
|
|
817
|
+
endTime: 30
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Example: Transform a specific segment (trim + mirror + speed)
|
|
821
|
+
const transformedClip = await TwoStepVideo.transformVideo({
|
|
822
|
+
assetId: asset.id,
|
|
823
|
+
speed: 0.5, // Slow motion
|
|
824
|
+
mirrorAxis: 'horizontal',
|
|
825
|
+
startTime: 45, // Best moment starts at 45s
|
|
826
|
+
endTime: 50 // 5 seconds of slow-mo mirrored footage
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
return transformedClip;
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### Create Social Media Loop with Mirror
|
|
834
|
+
|
|
835
|
+
Perfect for TikTok/Instagram Reels - select a short segment, mirror it, and loop it:
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
async function createSocialMediaLoop(
|
|
839
|
+
videoUri: string,
|
|
840
|
+
startTime: number,
|
|
841
|
+
endTime: number,
|
|
842
|
+
targetDuration: number = 15
|
|
843
|
+
) {
|
|
844
|
+
const asset = await TwoStepVideo.loadAsset({ uri: videoUri });
|
|
845
|
+
|
|
846
|
+
// First, create the mirrored and trimmed base clip
|
|
847
|
+
const mirroredClip = await TwoStepVideo.transformVideo({
|
|
848
|
+
assetId: asset.id,
|
|
849
|
+
mirrorAxis: 'horizontal',
|
|
850
|
+
startTime,
|
|
851
|
+
endTime
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Export the mirrored clip first (loopSegment needs the original asset)
|
|
855
|
+
const exportedMirror = await TwoStepVideo.exportVideo({
|
|
856
|
+
compositionId: mirroredClip.id,
|
|
857
|
+
quality: 'high'
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Load the exported mirrored video
|
|
861
|
+
const mirroredAsset = await TwoStepVideo.loadAsset({
|
|
862
|
+
uri: exportedMirror.uri
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Calculate how many loops needed to reach target duration
|
|
866
|
+
const clipDuration = endTime - startTime;
|
|
867
|
+
const loopCount = Math.ceil(targetDuration / clipDuration) - 1;
|
|
868
|
+
|
|
869
|
+
// Create the loop
|
|
870
|
+
const looped = await TwoStepVideo.loopSegment({
|
|
871
|
+
assetId: mirroredAsset.id,
|
|
872
|
+
startTime: 0,
|
|
873
|
+
endTime: mirroredAsset.duration,
|
|
874
|
+
loopCount
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
console.log(`Created ${looped.duration}s loop from ${clipDuration}s clip`);
|
|
878
|
+
|
|
879
|
+
// Export final result
|
|
880
|
+
const result = await TwoStepVideo.exportVideo({
|
|
881
|
+
compositionId: looped.id,
|
|
882
|
+
quality: 'high'
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// Cleanup intermediate files
|
|
886
|
+
TwoStepVideo.cleanupFile(exportedMirror.uri);
|
|
887
|
+
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Usage
|
|
892
|
+
const socialLoop = await createSocialMediaLoop(
|
|
893
|
+
'file:///path/to/selfie.mp4',
|
|
894
|
+
5.0, // Start at 5 seconds
|
|
895
|
+
8.0, // End at 8 seconds (3 second clip)
|
|
896
|
+
15 // Target 15 seconds for Instagram
|
|
897
|
+
);
|
|
898
|
+
```
|
|
899
|
+
|
|
294
900
|
### With Progress Bar
|
|
295
901
|
|
|
296
902
|
```typescript
|
package/expo-module.config.json
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"platforms": ["apple"
|
|
2
|
+
"platforms": ["apple"],
|
|
3
3
|
"apple": {
|
|
4
|
-
"modules": ["ExpoTwoStepVideoModule"]
|
|
5
|
-
"podspecPath": "ios/ExpoTwostepVideo.podspec"
|
|
6
|
-
},
|
|
7
|
-
"android": {
|
|
8
|
-
"modules": ["expo.modules.twostepvideo.ExpoTwoStepVideoModule"]
|
|
4
|
+
"modules": ["ExpoTwoStepVideoModule"]
|
|
9
5
|
}
|
|
10
6
|
}
|
|
@@ -10,10 +10,7 @@ Pod::Spec.new do |s|
|
|
|
10
10
|
s.license = package['license']
|
|
11
11
|
s.author = package['author']
|
|
12
12
|
s.homepage = package['homepage']
|
|
13
|
-
s.platforms = {
|
|
14
|
-
:ios => '16.0',
|
|
15
|
-
:tvos => '16.0'
|
|
16
|
-
}
|
|
13
|
+
s.platforms = { :ios => '16.0' }
|
|
17
14
|
s.swift_version = '5.9'
|
|
18
15
|
s.source = { git: 'https://github.com/rguo123/twostep-video' }
|
|
19
16
|
s.static_framework = true
|
|
@@ -25,6 +22,8 @@ Pod::Spec.new do |s|
|
|
|
25
22
|
'DEFINES_MODULE' => 'YES',
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
s.source_files =
|
|
29
|
-
|
|
25
|
+
s.source_files = [
|
|
26
|
+
"*.swift",
|
|
27
|
+
"TwoStepVideo/**/*.swift"
|
|
28
|
+
]
|
|
30
29
|
end
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@movementinfra/expo-twostep-video",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Minimal video editing for React Native using AVFoundation",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"build",
|
|
9
|
-
"ios",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"!ios/.swiftpm"
|
|
9
|
+
"ios/*.podspec",
|
|
10
|
+
"ios/*.swift",
|
|
11
|
+
"ios/TwoStepVideo",
|
|
12
|
+
"expo-module.config.json"
|
|
14
13
|
],
|
|
15
14
|
"scripts": {
|
|
16
15
|
"build": "expo-module build",
|