npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@movementinfra/expo-twostep-video

v0.2.1

Published

Minimal video editing for React Native using AVFoundation

Readme

expo-twostep-video

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

npm version License: MIT

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-video

Picture 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 file

Video 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 audio background 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 UIs
  • useExportProgress(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 both

Aspect 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

Links