@movementinfra/expo-twostep-video
v0.2.1
Published
Minimal video editing for React Native using AVFoundation
Maintainers
Readme
expo-twostep-video
Professional video editing for React Native, powered by native AVFoundation.
Features
- Trim videos with frame-accurate precision
- Mirror videos horizontally, vertically, or both
- Speed adjustment - slow motion (1/16x) to fast forward (16x)
- Loop segments - repeat video sections for perfect loops
- Pan & zoom - pinch-to-zoom with gesture controls
- Crop to social aspect ratios (9:16, 1:1, 4:5, 16:9) or freeform
- Ken Burns effect - keyframe-animated pan/zoom with eased interpolation
- Multi-segment compositions - create highlight reels
- Generate thumbnails at any timestamp
- Native video player with playback controls, fullscreen, and Picture in Picture
- Editing UI components - trimmer/scrubber, crop selector, Ken Burns editor
- Export with quality presets or passthrough (no re-encode)
- Real-time progress tracking and cancellation during exports
- Type-safe TypeScript API with full IntelliSense
Requirements
- iOS 16.0+
- Expo SDK 50+
- React Native 0.72+
Installation
npx expo install expo-twostep-videoPicture in Picture (optional)
For Picture in Picture playback to continue while the app is backgrounded, the
host app must declare the audio background mode. Add the config plugin to your
app config:
{
"expo": {
"plugins": ["expo-twostep-video"]
}
}Then rebuild the native project (npx expo prebuild / npx expo run:ios). The
plugin adds UIBackgroundModes: ["audio"] to Info.plist. In a bare project
without config plugins, add that key manually.
Quick Start
import * as TwoStepVideo from 'expo-twostep-video';
// Load a video
const asset = await TwoStepVideo.loadAsset({
uri: 'file:///path/to/video.mp4'
});
// Trim to 10 seconds
const composition = await TwoStepVideo.trimVideo({
assetId: asset.id,
startTime: 5.0,
endTime: 15.0
});
// Export
const result = await TwoStepVideo.exportVideo({
compositionId: composition.id,
quality: TwoStepVideo.Quality.HIGH
});
console.log('Exported to:', result.uri);
// Cleanup
TwoStepVideo.releaseAll();API Reference
Loading Videos
// Load from file URI
const asset = await TwoStepVideo.loadAsset({ uri: 'file:///path/to/video.mp4' });
// Returns: { id, duration, width, height, frameRate, hasAudio }
// Load from Photos library
const asset = await TwoStepVideo.loadAssetFromPhotos(photoAsset.id);
// Validate without loading
const isValid = await TwoStepVideo.validateVideoUri(uri);Trimming
// Single segment
const composition = await TwoStepVideo.trimVideo({
assetId: asset.id,
startTime: 5.0,
endTime: 15.0
});
// Multiple segments (highlight reel)
const composition = await TwoStepVideo.trimVideoMultiple({
assetId: asset.id,
segments: [
{ start: 0, end: 3 },
{ start: 10, end: 15 },
{ start: 20, end: 25 }
]
});Mirroring
// Horizontal (flip left-right) - common for selfie videos
const mirrored = await TwoStepVideo.mirrorVideo({
assetId: asset.id,
axis: 'horizontal'
});
// Vertical (flip top-bottom)
const flipped = await TwoStepVideo.mirrorVideo({
assetId: asset.id,
axis: 'vertical'
});
// Both axes
const both = await TwoStepVideo.mirrorVideo({
assetId: asset.id,
axis: 'both'
});Speed Adjustment
// Slow motion (0.5x = 2x slower)
const slowMo = await TwoStepVideo.adjustSpeed({
assetId: asset.id,
speed: 0.5
});
// Fast forward (2x speed)
const fast = await TwoStepVideo.adjustSpeed({
assetId: asset.id,
speed: 2.0
});
// Timelapse (4x speed) — supported range is 0.0625 (1/16x) to 16.0 (16x)
const timelapse = await TwoStepVideo.adjustSpeed({
assetId: asset.id,
speed: 4.0
});Looping
// Loop a 3-second segment 4 times (plays 5 times total = 15 seconds)
const looped = await TwoStepVideo.loopSegment({
assetId: asset.id,
startTime: 0,
endTime: 3,
loopCount: 4
});
console.log(`Duration: ${looped.duration}s, plays ${looped.totalPlays} times`);Combined Transformations
// Mirror + slow motion in one operation
const transformed = await TwoStepVideo.transformVideo({
assetId: asset.id,
speed: 0.5,
mirrorAxis: 'horizontal',
startTime: 0,
endTime: 10
});Pan & Zoom
// Apply pan/zoom for export (bakes transform into video)
const zoomed = await TwoStepVideo.panZoomVideo({
assetId: asset.id,
panX: 0.3, // Pan position (-1 to 1)
panY: -0.2,
zoomLevel: 1.5 // Zoom level (1 to 5)
});Cropping
originX/originY are the normalized center of the crop window (0–1,
0.5 = centered) and are clamped so the window always stays within the video
bounds. zoom (>= 1, default 1) shrinks the window to crop a smaller,
"zoomed in" region: window size = largest fit / zoom.
// Crop to portrait (9:16) for TikTok/Reels
const cropped = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio: '9:16'
});
console.log(`Output: ${cropped.width}x${cropped.height}`);
// Square crop, window centered near the top of the video
const square = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio: '1:1',
originY: 0.0
});
// Zoomed crop: a 2x-zoomed square region in the upper-left quadrant
const zoomed = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio: '1:1',
originX: 0.25,
originY: 0.25,
zoom: 2.0
});
// Freeform crop
const custom = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio: 'freeform',
freeformWidth: 800,
freeformHeight: 600
});
// Crop an existing composition (pipeline chaining: mirror -> crop)
const mirrored = await TwoStepVideo.mirrorVideo({ assetId: asset.id, axis: 'horizontal' });
const chained = await TwoStepVideo.cropComposition({
compositionId: mirrored.id,
aspectRatio: '9:16'
});Ken Burns Effect
Animate pan/zoom over time with keyframes. Requires a crop first to define
the output window. Keyframe times must be strictly ascending and each
zoomLevel must be >= 1; interpolation between keyframes is eased
(cubic ease-in-out).
// 1. Crop to define the output window
const cropped = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio: '9:16'
});
// 2. Animate: track a subject moving left to right
const animated = await TwoStepVideo.applyKenBurns({
compositionId: cropped.id,
keyframes: [
{ time: 0, panX: -0.5, panY: 0, zoomLevel: 1.5 },
{ time: 3, panX: 0, panY: 0, zoomLevel: 1.2 },
{ time: 6, panX: 0.5, panY: 0, zoomLevel: 1.5 },
]
});
// 3. Preview in the player, then export as usual
// <TwoStepVideoView compositionId={animated.id} loop />
// Return to the static (crop-only) state for per-keyframe editing.
// Also required before calling cropComposition on a composition with
// Ken Burns applied (it rejects with code "INVALID_PIPELINE" otherwise).
TwoStepVideo.revertToStaticCrop(cropped.id);Thumbnails
const thumbnails = await TwoStepVideo.generateThumbnails({
assetId: asset.id,
times: [0, 5, 10, 15],
size: { width: 300, height: 300 }
});
// Returns file:// JPEG URIs aligned with the requested times.
// Frames that fail yield "" at their position.
// Use in Image component (skip empty strings from failed frames)
{thumbnails.filter(Boolean).map((uri) => (
<Image key={uri} source={{ uri }} />
))}Exporting
// Export composition
const result = await TwoStepVideo.exportVideo({
compositionId: composition.id,
quality: TwoStepVideo.Quality.HIGH
});
// Export asset directly
const result = await TwoStepVideo.exportAsset({
assetId: asset.id,
quality: TwoStepVideo.Quality.MEDIUM
});
// Skip re-encoding entirely (fastest, original quality). Ignored when the
// composition has transforms (mirror, crop, Ken Burns, pan/zoom).
const copy = await TwoStepVideo.exportVideo({
compositionId: composition.id,
quality: TwoStepVideo.Quality.PASSTHROUGH
});
// Track progress
const subscription = TwoStepVideo.addExportProgressListener((event) => {
console.log(`${Math.round(event.progress * 100)}%`);
});
// Later: subscription.remove();
// Or use the React hook
const progress = TwoStepVideo.useExportProgress(composition.id); // 0.0 to 1.0
// Cancel an in-flight export — the pending export promise rejects
// with code "EXPORT_CANCELLED"
const cancelled = await TwoStepVideo.cancelExport(composition.id);Memory Management
TwoStepVideo.releaseAsset(asset.id);
TwoStepVideo.releaseComposition(composition.id);
TwoStepVideo.releaseAll(); // Release everything
TwoStepVideo.cleanupFile(result.uri); // Delete temp fileVideo Player Component
TwoStepVideoView
Native video player with pan/zoom gesture support:
import { TwoStepVideoView, TwoStepVideoViewRef } from 'expo-twostep-video';
function VideoPlayer({ compositionId }: { compositionId: string }) {
const playerRef = useRef<TwoStepVideoViewRef>(null);
return (
<View>
<TwoStepVideoView
ref={playerRef}
compositionId={compositionId}
loop={false}
onPlaybackStatusChange={(e) => console.log(e.nativeEvent.status)}
onProgress={(e) => console.log(e.nativeEvent.progress)}
onPanZoomChange={(e) => console.log(e.nativeEvent.zoomLevel)}
style={{ width: '100%', height: 300 }}
/>
<Button title="Play" onPress={() => playerRef.current?.play()} />
<Button title="Pause" onPress={() => playerRef.current?.pause()} />
<Button title="Seek 5s" onPress={() => playerRef.current?.seek(5)} />
</View>
);
}Props:
| Prop | Type | Description |
|------|------|-------------|
| compositionId | string | ID from trimVideo, mirrorVideo, etc. |
| assetId | string | ID from loadAsset (for raw playback) |
| loop | boolean | Enable continuous looping |
| minZoom | number | Minimum zoom level (default: 1.0) |
| maxZoom | number | Maximum zoom level (default: 5.0) |
| onPlaybackStatusChange | function | Status: ready, playing, paused, ended |
| onProgress | function | Progress: currentTime, duration, progress |
| onPanZoomChange | function | Pan/zoom: panX, panY, zoomLevel |
| onEnd | function | Called when playback ends |
| onError | function | Called on error |
Ref Methods:
await playerRef.current?.play();
await playerRef.current?.pause();
await playerRef.current?.seek(10.5);
await playerRef.current?.replay();
// Pan/zoom control
const state = await playerRef.current?.getPanZoomState();
await playerRef.current?.setPanZoomState({ zoomLevel: 2.0 });
await playerRef.current?.resetPanZoom();TwoStepPlayerControllerView
Native iOS player (AVPlayerViewController) with system controls — AirPlay,
Picture in Picture, and fullscreen:
import { TwoStepPlayerControllerView } from 'expo-twostep-video';
<TwoStepPlayerControllerView
assetId={asset.id}
showsPlaybackControls={true}
allowsPictureInPicture={true}
style={{ width: '100%', height: 300 }}
/>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| assetId | string | — | Loaded asset to play |
| compositionId | string | — | Composition to play (takes precedence over assetId) |
| loop | boolean | false | Loop playback continuously |
| showsPlaybackControls | boolean | true | Show the native transport controls |
| allowsPictureInPicture | boolean | true | Allow Picture in Picture playback |
Imperative methods (via ref): play(), pause(), seek(time), replay(),
enterFullscreen(), exitFullscreen().
Picture in Picture
PiP is enabled by default. The player shows the PiP button in its controls and
also starts PiP automatically when the app is backgrounded mid-playback. To
disable it, set allowsPictureInPicture={false}.
Notes:
- Background playback requires the config plugin (see
Installation). Without the
audiobackground mode, PiP starts but stops the moment the app leaves the foreground. - Playing video activates the app's audio session (
.playback), so playback is audible even when the ringer is on silent — this is required for PiP and is scoped to this player view. - PiP only works on a physical device, not the iOS Simulator.
Editing UI Components
Prebuilt React Native components for building an editing screen.
VideoScrubber
Trimmer/scrubber with thumbnail strip, draggable trim handles, and playhead:
import { VideoScrubber } from 'expo-twostep-video';
<VideoScrubber
assetId={asset.id}
duration={asset.duration}
currentTime={currentTime}
startTime={trimStart}
endTime={trimEnd}
onStartTimeChange={setTrimStart}
onEndTimeChange={setTrimEnd}
onScrubbing={(time) => playerRef.current?.seek(time)}
/>Optional props include thumbnailCount (default 10), thumbnailHeight
(default 50), minDuration (default 0.5s), onScrub/onScrubEnd, and a
theme for colors.
CropSelector
iOS-Photos-style crop editor. Pass the live player as children: it renders behind a fixed crop window, and one-finger drag pans / pinch zooms the video under the window. The reported region maps 1:1 onto the native crop.
import { CropSelector, TwoStepVideoView } from 'expo-twostep-video';
<CropSelector
sourceWidth={asset.width}
sourceHeight={asset.height}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
onCropChange={setCropRegion} // { originX, originY, zoom }
>
<TwoStepVideoView assetId={asset.id} style={{ flex: 1 }} />
</CropSelector>Spread the resulting region into cropVideo / cropComposition:
const result = await TwoStepVideo.cropVideo({
assetId: asset.id,
aspectRatio,
...cropRegion, // originX, originY, zoom
});Touches are not delivered to the children while cropping (the selector owns the gestures). Without children it still works as a plain region picker over a dark background.
KenBurnsEditor
Keyframe timeline for the Ken Burns effect — add, select, drag, and remove keyframes on a thumbnail strip:
import { KenBurnsEditor } from 'expo-twostep-video';
<KenBurnsEditor
assetId={asset.id}
duration={duration}
keyframes={keyframes}
selectedKeyframeIndex={selectedIndex}
currentTime={currentTime}
currentPose={panZoomState} // new keyframes inherit the player's pose
onKeyframesChange={setKeyframes}
onKeyframeSelect={setSelectedIndex}
onScrubbing={(time) => playerRef.current?.seek(time)}
/>Pass the keyframes to applyKenBurns to bake the animation.
Hooks
import { useVideoScrubber, useExportProgress } from 'expo-twostep-video';useVideoScrubber- playback/scrub state orchestration for custom scrubber UIsuseExportProgress(compositionId?)- current export progress (0.0 to 1.0)
Constants
Quality Presets
TwoStepVideo.Quality.LOW // Fast, small files
TwoStepVideo.Quality.MEDIUM // Good for web/social
TwoStepVideo.Quality.HIGH // Recommended default
TwoStepVideo.Quality.HIGHEST // Archival quality
TwoStepVideo.Quality.PASSTHROUGH // No re-encode (fastest, original quality)Mirror Axis
TwoStepVideo.Mirror.HORIZONTAL // Flip left-right
TwoStepVideo.Mirror.VERTICAL // Flip top-bottom
TwoStepVideo.Mirror.BOTH // Flip bothAspect Ratios
TwoStepVideo.AspectRatios.PORTRAIT // '9:16'
TwoStepVideo.AspectRatios.SQUARE // '1:1'
TwoStepVideo.AspectRatios.SOCIAL // '4:5'
TwoStepVideo.AspectRatios.LANDSCAPE // '16:9'TypeScript Types
import type {
VideoAsset,
VideoComposition,
ExportResult,
LoopResult,
TwoStepVideoViewRef,
TwoStepVideoViewProps,
PanZoomState,
MirrorAxis,
VideoQuality,
AspectRatio,
CropResult,
CropVideoOptions,
CropCompositionOptions,
KenBurnsKeyframe,
ApplyKenBurnsOptions,
VideoScrubberProps,
CropSelectorProps,
KenBurnsEditorProps,
} from 'expo-twostep-video';Platform Support
- ✅ iOS (fully supported)
- ⏳ Android (coming soon)
License
MIT © Richard Guo
