@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.
Files changed (58) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -0
  4. package/android/build.gradle +27 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
  7. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
  8. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
  9. package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
  10. package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
  11. package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
  12. package/eslint.config.cjs +16 -0
  13. package/expo-module.config.json +9 -0
  14. package/ios/AudioMixBuilder.swift +66 -0
  15. package/ios/ColorMatrixFilter.swift +24 -0
  16. package/ios/ExpoVideoEditor.podspec +23 -0
  17. package/ios/ExpoVideoEditorModule.swift +55 -0
  18. package/ios/ExpoVideoEditorView.swift +144 -0
  19. package/ios/ExportRunner.swift +135 -0
  20. package/ios/FrameComposer.swift +94 -0
  21. package/ios/ImageVideoWriter.swift +144 -0
  22. package/ios/Records.swift +41 -0
  23. package/package.json +36 -0
  24. package/src/components/EditorRoot.tsx +180 -0
  25. package/src/components/ExportOverlay.tsx +44 -0
  26. package/src/components/PreviewSurface.tsx +49 -0
  27. package/src/components/VideoEditor.tsx +51 -0
  28. package/src/components/audio/AudioPanel.tsx +69 -0
  29. package/src/components/audio/AudioScrubber.tsx +177 -0
  30. package/src/components/audio/Slider.tsx +60 -0
  31. package/src/components/filters/FilterCarousel.tsx +44 -0
  32. package/src/components/giphy/GiphyGrid.tsx +48 -0
  33. package/src/components/giphy/GiphyPicker.tsx +112 -0
  34. package/src/components/overlays/GifOverlayView.tsx +53 -0
  35. package/src/components/overlays/OverlayLayer.tsx +24 -0
  36. package/src/components/overlays/TextEditorSheet.tsx +135 -0
  37. package/src/components/overlays/TextOverlayView.tsx +61 -0
  38. package/src/components/timeline/ThumbnailStrip.tsx +63 -0
  39. package/src/components/timeline/TrimTimeline.tsx +152 -0
  40. package/src/errors.ts +33 -0
  41. package/src/filters/presets.ts +84 -0
  42. package/src/hooks/useAudioPreview.ts +94 -0
  43. package/src/hooks/useExport.ts +41 -0
  44. package/src/hooks/useOverlayGestures.ts +90 -0
  45. package/src/index.ts +29 -0
  46. package/src/native/EditorVideoView.tsx +27 -0
  47. package/src/native/VideoEditorModule.ts +51 -0
  48. package/src/overlays/captureRegistry.ts +12 -0
  49. package/src/services/demoCatalog.ts +30 -0
  50. package/src/services/exportService.ts +199 -0
  51. package/src/services/feedfmCatalog.ts +44 -0
  52. package/src/services/giphyClient.ts +69 -0
  53. package/src/services/mediaCache.ts +18 -0
  54. package/src/services/musicCatalog.ts +23 -0
  55. package/src/state/store.ts +200 -0
  56. package/src/theme.ts +26 -0
  57. package/src/types.ts +104 -0
  58. package/tsconfig.json +28 -0
package/.prettierrc ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "printWidth": 100,
3
+ "tabWidth": 2,
4
+ "singleQuote": true,
5
+ "bracketSameLine": true,
6
+ "trailingComma": "es5",
7
+ "jsxSingleQuote": false,
8
+ }
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,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -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
+ }