A compact, readable video editor built with Expo, React Native Skia and @azzapp/react-native-skia-video. It is meant to be read as a tutorial: every moving part of a mobile video editor — timeline, GPU compositing, shader transitions, draggable overlays, audio mixing, hardware-encoded export — in ~15 small files.
Pick clips → add music, text & images → choose a transition → export to your photo library.
▶ Full demo video (with sound)
- 🎞 Montage of up to 6 photos/videos from your gallery — or from 5 bundled island-themed samples (photos get a slow Ken Burns zoom)
- 🎵 Soundtrack: 3 bundled royalty-free tracks, looped over the montage and mixed into the export (gallery videos keep their own sound)
- ✍️ Text & image overlays: drag, pinch and rotate them right on the preview, pick font/color, give them an enter/exit animation
- ✨ Transitions between clips, written as Skia (SkSL) shaders: fade, slide, wipe, cross-zoom
- 📤 Export to a real 1080×1920@30 H.264 file, saved to the photo library
The app uses native modules (Skia, skia-video), so it needs a development build — Expo Go won't work:
npm install
npx expo prebuild
npx expo run:ios # or: npx expo run:androidEverything on screen is drawn by a single worklet,
src/editor/drawFrame.ts:
drawFrame({ canvas, currentTime, frames, state })
├─ clips + transition (video frames arrive as GPU textures)
├─ image overlays
└─ text overlays
- The preview calls it on every vsync via
useVideoCompositionPlayer. - The export calls it for every encoded frame on a background thread via
exportVideoComposition, feeding the resulting texture straight to the hardware encoder.
Same function, same state → what you preview is exactly what you export. No duplicated rendering logic to keep in sync, ever.
src/
├── editor/ the engine (no UI)
│ ├── types.ts the data model — geometry stored as canvas fractions
│ ├── state.ts plain useReducer, ~10 actions
│ ├── timeline.ts clip timing + VideoComposition builder
│ ├── transitions.ts SkSL transition shaders
│ ├── layerAnimations.ts overlay enter/exit poses
│ ├── drawFrame.ts ★ the renderer (preview AND export)
│ ├── skiaCache.ts per-runtime decode cache (images, paragraphs)
│ ├── assetRegistry.ts raw image bytes, shared across runtimes
│ ├── fonts.ts the shared Skia typeface provider
│ ├── assets.ts bundled samples, music and fonts
│ └── useExport.ts export + save to the photo library
├── components/
│ ├── VideoPreview.tsx player + overlay gestures + playback bar
│ ├── ClipStrip.tsx reorder/remove clips
│ ├── Toolbar.tsx tools / contextual layer actions
│ ├── sheets/ one bottom sheet per tool
│ └── ui/ small shadcn-style primitives (button, text, sheet…)
└── screens/EditorScreen.tsx
Timeline with transitions — clips have durations, not start times. Start
times are derived by an accumulator where each clip overlaps the previous one
by the transition duration. Inside the overlap two clips are live: the
drawer hands both to the transition shader, which blends them
(timeline.ts, drawFrame.ts).
Transitions are shaders — each one is a few lines of SkSL taking the two
frames as child shaders and a progress uniform. Adding a new transition is
copy-pasting a shader from gl-transitions into
transitions.ts.
Gestures never re-render React — dragging writes {dx, dy, scale, rotation} into a reanimated shared value that drawFrame merges over the
selected layer on the UI thread; React state is only updated once, when the
fingers lift (VideoPreview.tsx). Layer
hit-testing reuses the bounds that drawFrame computed while drawing.
Resolution independence — all layer geometry lives in fractions of the canvas, so the ~200pt preview and the 1080×1920 export render the exact same composition.
Assets across threads — images are kept as Uint8Arrays
(assetRegistry.ts) and each runtime decodes
and caches its own SkImages; the typeface provider is created once on the
JS thread and captured by the worklets (fonts.ts).
Paragraphs are laid out per runtime and cached — never rebuilt per frame
(skiaCache.ts).
Audio — the soundtrack is just kind: 'audio' items in the
VideoComposition; the native module plays them during preview and mixes
them into the exported file. Tracks shorter than the montage are chained
back-to-back (timeline.ts).
All bundled content is original and free to reuse: the island artwork and sample videos are procedurally generated vector scenes, and the three music tracks were synthesized for this project (no samples). Fonts (Poppins, Pacifico, Archivo Black) come from Google Fonts under the SIL Open Font License; icons are Remix Icon (Apache 2.0).
Heavily inspired by the cover editor of Azzapp, which pioneered the "one worklet for preview and export" architecture on top of @azzapp/react-native-skia-video.
MIT — see LICENSE.
