@igorpache/expo-video-editor 0.1.0
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/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/android/build.gradle +27 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
- package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
- package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
- package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
- package/eslint.config.cjs +16 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioMixBuilder.swift +66 -0
- package/ios/ColorMatrixFilter.swift +24 -0
- package/ios/ExpoVideoEditor.podspec +23 -0
- package/ios/ExpoVideoEditorModule.swift +55 -0
- package/ios/ExpoVideoEditorView.swift +144 -0
- package/ios/ExportRunner.swift +135 -0
- package/ios/FrameComposer.swift +94 -0
- package/ios/ImageVideoWriter.swift +144 -0
- package/ios/Records.swift +41 -0
- package/package.json +36 -0
- package/src/components/EditorRoot.tsx +180 -0
- package/src/components/ExportOverlay.tsx +44 -0
- package/src/components/PreviewSurface.tsx +49 -0
- package/src/components/VideoEditor.tsx +51 -0
- package/src/components/audio/AudioPanel.tsx +69 -0
- package/src/components/audio/AudioScrubber.tsx +177 -0
- package/src/components/audio/Slider.tsx +60 -0
- package/src/components/filters/FilterCarousel.tsx +44 -0
- package/src/components/giphy/GiphyGrid.tsx +48 -0
- package/src/components/giphy/GiphyPicker.tsx +112 -0
- package/src/components/overlays/GifOverlayView.tsx +53 -0
- package/src/components/overlays/OverlayLayer.tsx +24 -0
- package/src/components/overlays/TextEditorSheet.tsx +135 -0
- package/src/components/overlays/TextOverlayView.tsx +61 -0
- package/src/components/timeline/ThumbnailStrip.tsx +63 -0
- package/src/components/timeline/TrimTimeline.tsx +152 -0
- package/src/errors.ts +33 -0
- package/src/filters/presets.ts +84 -0
- package/src/hooks/useAudioPreview.ts +94 -0
- package/src/hooks/useExport.ts +41 -0
- package/src/hooks/useOverlayGestures.ts +90 -0
- package/src/index.ts +29 -0
- package/src/native/EditorVideoView.tsx +27 -0
- package/src/native/VideoEditorModule.ts +51 -0
- package/src/overlays/captureRegistry.ts +12 -0
- package/src/services/demoCatalog.ts +30 -0
- package/src/services/exportService.ts +199 -0
- package/src/services/feedfmCatalog.ts +44 -0
- package/src/services/giphyClient.ts +69 -0
- package/src/services/mediaCache.ts +18 -0
- package/src/services/musicCatalog.ts +23 -0
- package/src/state/store.ts +200 -0
- package/src/theme.ts +26 -0
- package/src/types.ts +104 -0
- package/tsconfig.json +28 -0
package/.prettierrc
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# expo-video-editor
|
|
2
|
+
|
|
3
|
+
High-performance, Stories-style video editor for Expo / React Native. Trim, color
|
|
4
|
+
filters, draggable text and Giphy GIF overlays, and a background-music mix — exported
|
|
5
|
+
in a **single hardware-encoded pass** with **no FFmpeg** (Media3 Transformer on Android,
|
|
6
|
+
AVFoundation on iOS).
|
|
7
|
+
|
|
8
|
+
- **Fast:** hardware decode/encode, GPU effects, gesture work on the UI thread.
|
|
9
|
+
- **What you see is what you export:** the same color matrix drives the live preview and
|
|
10
|
+
the export.
|
|
11
|
+
- **Agnostic audio:** any local or remote URI — you resolve the source, the editor mixes it.
|
|
12
|
+
|
|
13
|
+
> Requires a **dev build** (custom native module — not Expo Go), Expo SDK **57+**, and the
|
|
14
|
+
> New Architecture (default on SDK 57).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx expo install expo-video-editor \
|
|
22
|
+
react-native-gesture-handler react-native-reanimated react-native-worklets \
|
|
23
|
+
react-native-view-shot expo-image expo-audio expo-file-system expo-video-thumbnails
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then build a dev client:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx expo run:ios # or run:android
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Peer dependencies
|
|
33
|
+
|
|
34
|
+
| Package | Why |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `react-native-gesture-handler`, `react-native-reanimated` (+ `react-native-worklets`) | Overlay drag/pinch/rotate, trim + audio handles |
|
|
37
|
+
| `react-native-view-shot` | Rasterizes text overlays at export |
|
|
38
|
+
| `expo-image` | GIF rendering (Giphy grid + preview) |
|
|
39
|
+
| `expo-audio` | Music preview + segment pre-listen |
|
|
40
|
+
| `expo-file-system` | Downloads remote GIF/audio before export |
|
|
41
|
+
| `expo-video-thumbnails` | Trim-timeline frames |
|
|
42
|
+
|
|
43
|
+
The host app declares its own media permissions (to pick the video/audio). No custom
|
|
44
|
+
config plugin is required by this library.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { VideoEditor } from 'expo-video-editor';
|
|
52
|
+
|
|
53
|
+
<VideoEditor
|
|
54
|
+
source={{ uri: pickedVideoUri }}
|
|
55
|
+
giphy={{ apiKey: process.env.GIPHY_KEY }} // omit to hide the GIF button
|
|
56
|
+
audio={{ onPickAudio: pickAudioFile }} // omit to hide the Music button
|
|
57
|
+
export={{ resolution: 1080 }}
|
|
58
|
+
onExportComplete={(result) => save(result.uri)}
|
|
59
|
+
onExportError={(err) => console.warn(err)}
|
|
60
|
+
onCancel={() => goBack()}
|
|
61
|
+
/>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`onPickAudio` returns any audio URI you resolve — a file picker, a music SDK, a recording:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
async function pickAudioFile() {
|
|
68
|
+
const res = await DocumentPicker.getDocumentAsync({ type: 'audio/*' });
|
|
69
|
+
return res.canceled ? null : { uri: res.assets[0].uri, title: res.assets[0].name };
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
### `<VideoEditor>` props
|
|
78
|
+
|
|
79
|
+
| Prop | Type | Notes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `source` | `{ uri: string }` | The video to edit (required) |
|
|
82
|
+
| `giphy` | `{ apiKey: string }` | Enables the GIF button |
|
|
83
|
+
| `audio` | `{ onPickAudio?: () => Promise<AudioSource \| null> }` | Enables the Music button |
|
|
84
|
+
| `export` | `{ resolution?: 720 \| 1080 \| 'source'; bitrate?: number }` | Export options |
|
|
85
|
+
| `theme` | `Partial<EditorTheme>` | Color overrides |
|
|
86
|
+
| `onExportStart / Progress / Complete / Error` | callbacks | `Progress` is `0..1` |
|
|
87
|
+
| `onCancel` | `() => void` | Fired when the user cancels |
|
|
88
|
+
|
|
89
|
+
### Also exported
|
|
90
|
+
|
|
91
|
+
- `exportVideo(state, options, onProgress)` — imperative, headless export.
|
|
92
|
+
- `useVideoEditor()` — the Zustand store (advanced/headless control).
|
|
93
|
+
- `FILTER_PRESETS`, `getFilterMatrix(key)` — the shared color-matrix presets.
|
|
94
|
+
- `EditorError` (with `code`), and all `types`.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## How export works
|
|
99
|
+
|
|
100
|
+
Overlays are interactive **React Native views** while editing. At export they become
|
|
101
|
+
**data** — text is rasterized (view-shot) to the video resolution; GIFs are decoded
|
|
102
|
+
natively and the frame matching each timestamp is drawn. Everything (trim, color matrix,
|
|
103
|
+
overlays, audio mix) is applied in one encode. Trim with no effects **transmuxes** (no
|
|
104
|
+
re-encode, near-instant).
|
|
105
|
+
|
|
106
|
+
## Known limitations (v1)
|
|
107
|
+
|
|
108
|
+
- `export.resolution` / `bitrate` are plumbed but not yet applied — exports use the source
|
|
109
|
+
resolution. (Downscaling on re-encode is planned.)
|
|
110
|
+
- Overlays are always visible for the whole clip (`timeRange` is modeled; per-overlay
|
|
111
|
+
timing UI is planned).
|
|
112
|
+
- Filter looks are hand-tuned approximations; fine-tune the matrices in `filters/presets`.
|
|
113
|
+
- Android audio mixing uses the newest Media3 APIs — validate on device.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'expo.modules.videoeditor'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "expo.modules.videoeditor"
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName "0.1.0"
|
|
14
|
+
}
|
|
15
|
+
lintOptions {
|
|
16
|
+
abortOnError false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
dependencies {
|
|
21
|
+
def media3 = "1.8.0"
|
|
22
|
+
implementation "androidx.media3:media3-exoplayer:$media3"
|
|
23
|
+
implementation "androidx.media3:media3-ui:$media3"
|
|
24
|
+
implementation "androidx.media3:media3-transformer:$media3"
|
|
25
|
+
implementation "androidx.media3:media3-effect:$media3"
|
|
26
|
+
implementation "androidx.media3:media3-common:$media3"
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package expo.modules.videoeditor
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.util.UnstableApi
|
|
4
|
+
import androidx.media3.effect.RgbMatrix
|
|
5
|
+
|
|
6
|
+
@UnstableApi
|
|
7
|
+
class ColorMatrixEffect(matrix45: FloatArray) : RgbMatrix {
|
|
8
|
+
private val glMatrix: FloatArray = toColumnMajor(matrix45)
|
|
9
|
+
|
|
10
|
+
override fun getMatrix(presentationTimeUs: Long, useHdr: Boolean): FloatArray = glMatrix
|
|
11
|
+
|
|
12
|
+
companion object {
|
|
13
|
+
fun toColumnMajor(m: FloatArray): FloatArray {
|
|
14
|
+
val rr = m[0]; val rg = m[1]; val rb = m[2]; val rBias = m[4]
|
|
15
|
+
val gr = m[5]; val gg = m[6]; val gb = m[7]; val gBias = m[9]
|
|
16
|
+
val br = m[10]; val bg = m[11]; val bb = m[12]; val bBias = m[14]
|
|
17
|
+
return floatArrayOf(
|
|
18
|
+
rr, gr, br, 0f,
|
|
19
|
+
rg, gg, bg, 0f,
|
|
20
|
+
rb, gb, bb, 0f,
|
|
21
|
+
rBias, gBias, bBias, 1f,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package expo.modules.videoeditor
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.util.UnstableApi
|
|
4
|
+
import expo.modules.kotlin.Promise
|
|
5
|
+
import expo.modules.kotlin.modules.Module
|
|
6
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
7
|
+
|
|
8
|
+
@UnstableApi
|
|
9
|
+
class ExpoVideoEditorModule : Module() {
|
|
10
|
+
private var exportRunner: ExportRunner? = null
|
|
11
|
+
|
|
12
|
+
override fun definition() = ModuleDefinition {
|
|
13
|
+
Name("ExpoVideoEditor")
|
|
14
|
+
|
|
15
|
+
Events("onExportProgress")
|
|
16
|
+
|
|
17
|
+
AsyncFunction("ping") {
|
|
18
|
+
"ExpoVideoEditor 0.1.0 (android)"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
AsyncFunction("exportVideo") { payload: ExportPayload, promise: Promise ->
|
|
22
|
+
val context = appContext.reactContext
|
|
23
|
+
if (context == null) {
|
|
24
|
+
promise.reject("EXPORT_FAILED", "No React context available", null)
|
|
25
|
+
return@AsyncFunction
|
|
26
|
+
}
|
|
27
|
+
val runner = ExportRunner(context) { progress ->
|
|
28
|
+
sendEvent("onExportProgress", mapOf("progress" to progress))
|
|
29
|
+
}
|
|
30
|
+
exportRunner = runner
|
|
31
|
+
runner.export(payload, promise)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Function("cancelExport") {
|
|
35
|
+
exportRunner?.cancel()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
View(ExpoVideoEditorView::class) {
|
|
39
|
+
Events("onLoad", "onProgress", "onEnded")
|
|
40
|
+
|
|
41
|
+
Prop("source") { view: ExpoVideoEditorView, source: VideoSourceRecord ->
|
|
42
|
+
view.setSource(source.uri)
|
|
43
|
+
}
|
|
44
|
+
Prop("paused") { view: ExpoVideoEditorView, paused: Boolean ->
|
|
45
|
+
view.setPaused(paused)
|
|
46
|
+
}
|
|
47
|
+
Prop("loopStartMs") { view: ExpoVideoEditorView, ms: Int ->
|
|
48
|
+
view.setLoopStart(ms.toLong())
|
|
49
|
+
}
|
|
50
|
+
Prop("loopEndMs") { view: ExpoVideoEditorView, ms: Int ->
|
|
51
|
+
view.setLoopEnd(ms.toLong())
|
|
52
|
+
}
|
|
53
|
+
Prop("seek") { view: ExpoVideoEditorView, seek: SeekRecord ->
|
|
54
|
+
view.seekTo(seek.positionMs.toLong(), seek.seq)
|
|
55
|
+
}
|
|
56
|
+
Prop("colorMatrix") { view: ExpoVideoEditorView, matrix: List<Double> ->
|
|
57
|
+
view.setColorMatrix(matrix)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package expo.modules.videoeditor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import androidx.media3.common.MediaItem
|
|
8
|
+
import androidx.media3.common.Player
|
|
9
|
+
import androidx.media3.common.VideoSize
|
|
10
|
+
import androidx.media3.common.util.UnstableApi
|
|
11
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
12
|
+
import androidx.media3.ui.AspectRatioFrameLayout
|
|
13
|
+
import androidx.media3.ui.PlayerView
|
|
14
|
+
import expo.modules.kotlin.AppContext
|
|
15
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
16
|
+
import expo.modules.kotlin.views.ExpoView
|
|
17
|
+
|
|
18
|
+
@UnstableApi
|
|
19
|
+
class ExpoVideoEditorView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
20
|
+
private val onLoad by EventDispatcher<Map<String, Any>>()
|
|
21
|
+
private val onProgress by EventDispatcher<Map<String, Any>>()
|
|
22
|
+
private val onEnded by EventDispatcher<Map<String, Any>>()
|
|
23
|
+
|
|
24
|
+
private val exo = ExoPlayer.Builder(context).build()
|
|
25
|
+
private val playerView = PlayerView(context).apply {
|
|
26
|
+
useController = false
|
|
27
|
+
player = exo
|
|
28
|
+
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private var loopStartMs: Long = 0
|
|
32
|
+
private var loopEndMs: Long = 0
|
|
33
|
+
private var lastSeekSeq: Int = -1
|
|
34
|
+
private var didEmitLoad = false
|
|
35
|
+
|
|
36
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
37
|
+
private val ticker = object : Runnable {
|
|
38
|
+
override fun run() {
|
|
39
|
+
val pos = exo.currentPosition
|
|
40
|
+
val end = if (loopEndMs > 0) loopEndMs else exo.duration
|
|
41
|
+
if (end > 0 && pos >= end) {
|
|
42
|
+
exo.seekTo(if (loopStartMs > 0) loopStartMs else 0)
|
|
43
|
+
}
|
|
44
|
+
onProgress(mapOf("positionMs" to pos.toDouble()))
|
|
45
|
+
handler.postDelayed(this, 50)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
init {
|
|
50
|
+
addView(playerView)
|
|
51
|
+
exo.addListener(object : Player.Listener {
|
|
52
|
+
override fun onPlaybackStateChanged(state: Int) {
|
|
53
|
+
if (state == Player.STATE_READY && !didEmitLoad) {
|
|
54
|
+
didEmitLoad = true
|
|
55
|
+
val vs: VideoSize = exo.videoSize
|
|
56
|
+
onLoad(
|
|
57
|
+
mapOf(
|
|
58
|
+
"durationMs" to exo.duration.coerceAtLeast(0).toDouble(),
|
|
59
|
+
"width" to vs.width,
|
|
60
|
+
"height" to vs.height,
|
|
61
|
+
"rotationDeg" to vs.unappliedRotationDegrees
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
if (state == Player.STATE_ENDED) onEnded(emptyMap())
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
handler.post(ticker)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fun setSource(uri: String) {
|
|
72
|
+
if (uri.isEmpty()) return
|
|
73
|
+
didEmitLoad = false
|
|
74
|
+
exo.setMediaItem(MediaItem.fromUri(Uri.parse(uri)))
|
|
75
|
+
exo.prepare()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fun setPaused(paused: Boolean) {
|
|
79
|
+
exo.playWhenReady = !paused
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun setLoopStart(ms: Long) {
|
|
83
|
+
loopStartMs = ms
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun setLoopEnd(ms: Long) {
|
|
87
|
+
loopEndMs = ms
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fun seekTo(positionMs: Long, seq: Int) {
|
|
91
|
+
if (seq == lastSeekSeq) return
|
|
92
|
+
lastSeekSeq = seq
|
|
93
|
+
exo.seekTo(positionMs.coerceAtLeast(0))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun setColorMatrix(matrix: List<Double>) {
|
|
97
|
+
if (matrix.size >= 20) {
|
|
98
|
+
exo.setVideoEffects(listOf(ColorMatrixEffect(FloatArray(20) { matrix[it].toFloat() })))
|
|
99
|
+
} else {
|
|
100
|
+
exo.setVideoEffects(emptyList())
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun onDetachedFromWindow() {
|
|
105
|
+
super.onDetachedFromWindow()
|
|
106
|
+
handler.removeCallbacks(ticker)
|
|
107
|
+
exo.release()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
package expo.modules.videoeditor
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.graphics.Canvas
|
|
7
|
+
import android.graphics.Movie
|
|
8
|
+
import android.media.MediaMetadataRetriever
|
|
9
|
+
import android.net.Uri
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import androidx.media3.common.Effect
|
|
13
|
+
import androidx.media3.common.MediaItem
|
|
14
|
+
import androidx.media3.common.MimeTypes
|
|
15
|
+
import androidx.media3.common.audio.AudioProcessor
|
|
16
|
+
import androidx.media3.common.audio.ChannelMixingAudioProcessor
|
|
17
|
+
import androidx.media3.common.audio.ChannelMixingMatrix
|
|
18
|
+
import androidx.media3.common.util.UnstableApi
|
|
19
|
+
import androidx.media3.effect.BitmapOverlay
|
|
20
|
+
import androidx.media3.effect.OverlayEffect
|
|
21
|
+
import androidx.media3.effect.Presentation
|
|
22
|
+
import androidx.media3.effect.StaticOverlaySettings
|
|
23
|
+
import androidx.media3.effect.TextureOverlay
|
|
24
|
+
import androidx.media3.transformer.Composition
|
|
25
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
26
|
+
import androidx.media3.transformer.EditedMediaItemSequence
|
|
27
|
+
import androidx.media3.transformer.Effects
|
|
28
|
+
import androidx.media3.transformer.ExportException
|
|
29
|
+
import androidx.media3.transformer.ExportResult
|
|
30
|
+
import androidx.media3.transformer.ProgressHolder
|
|
31
|
+
import androidx.media3.transformer.Transformer
|
|
32
|
+
import com.google.common.collect.ImmutableList
|
|
33
|
+
import expo.modules.kotlin.Promise
|
|
34
|
+
import java.io.File
|
|
35
|
+
import java.io.FileInputStream
|
|
36
|
+
import java.io.InputStream
|
|
37
|
+
|
|
38
|
+
@UnstableApi
|
|
39
|
+
class ExportRunner(
|
|
40
|
+
private val context: Context,
|
|
41
|
+
private val onProgress: (Double) -> Unit
|
|
42
|
+
) {
|
|
43
|
+
private var transformer: Transformer? = null
|
|
44
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
45
|
+
private val progressHolder = ProgressHolder()
|
|
46
|
+
private var polling = false
|
|
47
|
+
|
|
48
|
+
private val poller = object : Runnable {
|
|
49
|
+
override fun run() {
|
|
50
|
+
val t = transformer ?: return
|
|
51
|
+
val state = t.getProgress(progressHolder)
|
|
52
|
+
if (state != Transformer.PROGRESS_STATE_NOT_STARTED) {
|
|
53
|
+
onProgress(progressHolder.progress / 100.0)
|
|
54
|
+
}
|
|
55
|
+
if (polling) mainHandler.postDelayed(this, 200)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun export(payload: ExportPayload, promise: Promise) {
|
|
60
|
+
mainHandler.post {
|
|
61
|
+
try {
|
|
62
|
+
val outFile = File(context.cacheDir, "vieditor-export-${System.nanoTime()}.mp4")
|
|
63
|
+
|
|
64
|
+
val isImage = payload.imageDurationMs > 0
|
|
65
|
+
|
|
66
|
+
val clipping = if (!isImage && payload.endMs > payload.startMs) {
|
|
67
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
68
|
+
.setStartPositionMs(payload.startMs.toLong())
|
|
69
|
+
.setEndPositionMs(payload.endMs.toLong())
|
|
70
|
+
.build()
|
|
71
|
+
} else {
|
|
72
|
+
MediaItem.ClippingConfiguration.UNSET
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
val mediaItemBuilder = MediaItem.Builder()
|
|
76
|
+
.setUri(Uri.parse(payload.sourceUri))
|
|
77
|
+
.setClippingConfiguration(clipping)
|
|
78
|
+
if (isImage) {
|
|
79
|
+
mediaItemBuilder
|
|
80
|
+
.setMimeType(MimeTypes.IMAGE_JPEG)
|
|
81
|
+
.setImageDurationMs(payload.imageDurationMs.toLong())
|
|
82
|
+
}
|
|
83
|
+
val mediaItem = mediaItemBuilder.build()
|
|
84
|
+
|
|
85
|
+
val videoEffects = mutableListOf<Effect>()
|
|
86
|
+
if (payload.colorMatrix.size >= 20) {
|
|
87
|
+
videoEffects.add(ColorMatrixEffect(FloatArray(20) { payload.colorMatrix[it].toFloat() }))
|
|
88
|
+
}
|
|
89
|
+
if (payload.overlays.isNotEmpty()) {
|
|
90
|
+
val (outW, outH) = if (isImage) readImageSize(payload.sourceUri) else readSourceSize(payload.sourceUri)
|
|
91
|
+
val overlays = payload.overlays.mapNotNull { buildOverlay(it, outW, outH) }
|
|
92
|
+
if (overlays.isNotEmpty()) {
|
|
93
|
+
videoEffects.add(OverlayEffect(ImmutableList.copyOf(overlays)))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// H.264/HEVC encoders reject odd dimensions (ERROR_CODE_ENCODING_FORMAT_UNSUPPORTED).
|
|
97
|
+
// A baked image can have an odd width/height, so force even output dimensions.
|
|
98
|
+
if (isImage) {
|
|
99
|
+
val (iw, ih) = readImageSize(payload.sourceUri)
|
|
100
|
+
val ew = iw - (iw % 2)
|
|
101
|
+
val eh = ih - (ih % 2)
|
|
102
|
+
if (ew > 0 && eh > 0 && (ew != iw || eh != ih)) {
|
|
103
|
+
videoEffects.add(Presentation.createForWidthAndHeight(ew, eh, Presentation.LAYOUT_SCALE_TO_FIT))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
val audio = payload.audio
|
|
107
|
+
val originalAudioProcessors: List<AudioProcessor> =
|
|
108
|
+
if (!isImage && audio != null && payload.originalVolume != 1.0) {
|
|
109
|
+
listOf(volumeProcessor(payload.originalVolume.toFloat()))
|
|
110
|
+
} else {
|
|
111
|
+
emptyList()
|
|
112
|
+
}
|
|
113
|
+
val videoEditedBuilder = EditedMediaItem.Builder(mediaItem)
|
|
114
|
+
.setEffects(Effects(originalAudioProcessors, videoEffects))
|
|
115
|
+
if (isImage) {
|
|
116
|
+
videoEditedBuilder.setFrameRate(30)
|
|
117
|
+
}
|
|
118
|
+
val videoEdited = videoEditedBuilder.build()
|
|
119
|
+
|
|
120
|
+
val t = Transformer.Builder(context)
|
|
121
|
+
.addListener(object : Transformer.Listener {
|
|
122
|
+
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
123
|
+
stopPolling()
|
|
124
|
+
onProgress(1.0)
|
|
125
|
+
promise.resolve(readResult(outFile, payload))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override fun onError(
|
|
129
|
+
composition: Composition,
|
|
130
|
+
exportResult: ExportResult,
|
|
131
|
+
exception: ExportException
|
|
132
|
+
) {
|
|
133
|
+
stopPolling()
|
|
134
|
+
val detail = "${exception.errorCodeName}: ${exception.cause?.message ?: exception.message}"
|
|
135
|
+
android.util.Log.e("ExpoVideoEditor", "Export failed ($detail)", exception)
|
|
136
|
+
promise.reject("EXPORT_FAILED", detail, exception)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.build()
|
|
140
|
+
|
|
141
|
+
transformer = t
|
|
142
|
+
if (audio != null) {
|
|
143
|
+
val clipping = if (audio.trimEndMs > audio.trimStartMs) {
|
|
144
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
145
|
+
.setStartPositionMs(audio.trimStartMs.toLong())
|
|
146
|
+
.setEndPositionMs(audio.trimEndMs.toLong())
|
|
147
|
+
.build()
|
|
148
|
+
} else {
|
|
149
|
+
MediaItem.ClippingConfiguration.UNSET
|
|
150
|
+
}
|
|
151
|
+
val musicItem = MediaItem.Builder()
|
|
152
|
+
.setUri(Uri.parse(audio.uri))
|
|
153
|
+
.setClippingConfiguration(clipping)
|
|
154
|
+
.build()
|
|
155
|
+
val musicEdited = EditedMediaItem.Builder(musicItem)
|
|
156
|
+
.setRemoveVideo(true)
|
|
157
|
+
.setEffects(Effects(listOf(volumeProcessor(audio.volume.toFloat())), emptyList()))
|
|
158
|
+
.build()
|
|
159
|
+
val videoSequence = EditedMediaItemSequence.Builder(videoEdited).build()
|
|
160
|
+
val musicSequence = EditedMediaItemSequence.Builder(musicEdited).setIsLooping(audio.loop).build()
|
|
161
|
+
val composition = Composition.Builder(videoSequence, musicSequence).build()
|
|
162
|
+
t.start(composition, outFile.absolutePath)
|
|
163
|
+
} else {
|
|
164
|
+
t.start(videoEdited, outFile.absolutePath)
|
|
165
|
+
}
|
|
166
|
+
startPolling()
|
|
167
|
+
} catch (e: Exception) {
|
|
168
|
+
promise.reject("EXPORT_FAILED", e.message ?: "Export setup failed", e)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fun cancel() {
|
|
174
|
+
mainHandler.post {
|
|
175
|
+
transformer?.cancel()
|
|
176
|
+
stopPolling()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun startPolling() {
|
|
181
|
+
polling = true
|
|
182
|
+
mainHandler.post(poller)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fun stopPolling() {
|
|
186
|
+
polling = false
|
|
187
|
+
mainHandler.removeCallbacks(poller)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun volumeProcessor(volume: Float): AudioProcessor {
|
|
191
|
+
val processor = ChannelMixingAudioProcessor()
|
|
192
|
+
processor.putChannelMixingMatrix(ChannelMixingMatrix.create(1, 1).scaleBy(volume))
|
|
193
|
+
processor.putChannelMixingMatrix(ChannelMixingMatrix.create(2, 2).scaleBy(volume))
|
|
194
|
+
return processor
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun readSourceSize(uri: String): Pair<Int, Int> {
|
|
198
|
+
val retriever = MediaMetadataRetriever()
|
|
199
|
+
return try {
|
|
200
|
+
retriever.setDataSource(context, Uri.parse(uri))
|
|
201
|
+
val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
|
202
|
+
val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
|
203
|
+
val rot = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
204
|
+
if (rot == 90 || rot == 270) Pair(h, w) else Pair(w, h)
|
|
205
|
+
} catch (_: Exception) {
|
|
206
|
+
Pair(0, 0)
|
|
207
|
+
} finally {
|
|
208
|
+
retriever.release()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fun readImageSize(uri: String): Pair<Int, Int> {
|
|
213
|
+
return try {
|
|
214
|
+
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
215
|
+
openStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
|
216
|
+
Pair(opts.outWidth, opts.outHeight)
|
|
217
|
+
} catch (_: Exception) {
|
|
218
|
+
Pair(0, 0)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun openStream(uriStr: String): InputStream? {
|
|
223
|
+
return try {
|
|
224
|
+
val uri = Uri.parse(uriStr)
|
|
225
|
+
if (uri.scheme == null || uri.scheme == "file") FileInputStream(uri.path ?: uriStr)
|
|
226
|
+
else context.contentResolver.openInputStream(uri)
|
|
227
|
+
} catch (_: Exception) {
|
|
228
|
+
null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private fun loadBitmap(uriStr: String): Bitmap? =
|
|
233
|
+
openStream(uriStr)?.use { runCatching { BitmapFactory.decodeStream(it) }.getOrNull() }
|
|
234
|
+
|
|
235
|
+
private fun decodeGifFrames(uriStr: String): Pair<List<Bitmap>, LongArray>? {
|
|
236
|
+
val movie = openStream(uriStr)?.use { Movie.decodeStream(it) } ?: return null
|
|
237
|
+
val w = movie.width()
|
|
238
|
+
val h = movie.height()
|
|
239
|
+
if (w <= 0 || h <= 0) return null
|
|
240
|
+
val duration = movie.duration().coerceAtLeast(1)
|
|
241
|
+
val stepMs = 50
|
|
242
|
+
val frames = mutableListOf<Bitmap>()
|
|
243
|
+
var t = 0
|
|
244
|
+
while (t < duration) {
|
|
245
|
+
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
246
|
+
movie.setTime(t)
|
|
247
|
+
movie.draw(Canvas(bmp), 0f, 0f)
|
|
248
|
+
frames.add(bmp)
|
|
249
|
+
t += stepMs
|
|
250
|
+
}
|
|
251
|
+
if (frames.isEmpty()) return null
|
|
252
|
+
return Pair(frames, LongArray(frames.size) { stepMs * 1000L })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private fun overlaySettings(rec: OverlayRecord, bmpW: Int, bmpH: Int, outW: Int, outH: Int): StaticOverlaySettings {
|
|
256
|
+
val sx = (rec.widthFraction * outW / bmpW).toFloat()
|
|
257
|
+
val sy = (rec.heightFraction * outH / bmpH).toFloat()
|
|
258
|
+
val bgX = (rec.centerX * 2 - 1).toFloat()
|
|
259
|
+
val bgY = (1 - rec.centerY * 2).toFloat()
|
|
260
|
+
return StaticOverlaySettings.Builder()
|
|
261
|
+
.setScale(sx, sy)
|
|
262
|
+
.setRotationDegrees((-rec.rotationDeg).toFloat())
|
|
263
|
+
.setOverlayFrameAnchor(0f, 0f)
|
|
264
|
+
.setBackgroundFrameAnchor(bgX, bgY)
|
|
265
|
+
.build()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private fun buildOverlay(rec: OverlayRecord, outW: Int, outH: Int): TextureOverlay? {
|
|
269
|
+
if (outW <= 0 || outH <= 0) return null
|
|
270
|
+
if (rec.animated) {
|
|
271
|
+
val (frames, durations) = decodeGifFrames(rec.uri) ?: return null
|
|
272
|
+
return GifOverlay(frames, durations, overlaySettings(rec, frames[0].width, frames[0].height, outW, outH))
|
|
273
|
+
}
|
|
274
|
+
val bmp = loadBitmap(rec.uri) ?: return null
|
|
275
|
+
if (bmp.width <= 0 || bmp.height <= 0) return null
|
|
276
|
+
return BitmapOverlay.createStaticBitmapOverlay(bmp, overlaySettings(rec, bmp.width, bmp.height, outW, outH))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private fun readResult(file: File, payload: ExportPayload): Map<String, Any> {
|
|
280
|
+
var width = 0
|
|
281
|
+
var height = 0
|
|
282
|
+
var durationMs = (payload.endMs - payload.startMs).toLong().coerceAtLeast(0)
|
|
283
|
+
val retriever = MediaMetadataRetriever()
|
|
284
|
+
try {
|
|
285
|
+
retriever.setDataSource(file.absolutePath)
|
|
286
|
+
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
|
287
|
+
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
|
288
|
+
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()?.let {
|
|
289
|
+
if (it > 0) durationMs = it
|
|
290
|
+
}
|
|
291
|
+
} catch (_: Exception) {
|
|
292
|
+
} finally {
|
|
293
|
+
retriever.release()
|
|
294
|
+
}
|
|
295
|
+
return mapOf(
|
|
296
|
+
"uri" to Uri.fromFile(file).toString(),
|
|
297
|
+
"width" to width,
|
|
298
|
+
"height" to height,
|
|
299
|
+
"durationMs" to durationMs.toDouble()
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|