expo-pro-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 (125) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +67 -0
  3. package/NOTICE.md +250 -0
  4. package/README.md +70 -0
  5. package/android/build.gradle +35 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/expo/modules/provideoeditor/ExpoProVideoEditorModule.kt +163 -0
  8. package/android/src/main/java/expo/modules/provideoeditor/src/core/constants/LoggingConstants.kt +4 -0
  9. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/EffectsProcessor.kt +66 -0
  10. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/RenderVideo.kt +1089 -0
  11. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyAnimation.kt +290 -0
  12. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBitrate.kt +110 -0
  13. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt +27 -0
  14. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyChromaKey.kt +58 -0
  15. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyClipTransition.kt +88 -0
  16. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyColorMatrix.kt +144 -0
  17. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyComposition.kt +86 -0
  18. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyCrop.kt +104 -0
  19. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFlip.kt +27 -0
  20. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFrameRate.kt +28 -0
  21. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyImageLayer.kt +397 -0
  22. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyOpacity.kt +24 -0
  23. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyPlaybackSpeed.kt +35 -0
  24. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyRotation.kt +26 -0
  25. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyScale.kt +29 -0
  26. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioPreRenderer.kt +492 -0
  27. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioSequenceBuilder.kt +144 -0
  28. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/BitrateCapPolicy.kt +45 -0
  29. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyEffect.kt +321 -0
  30. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyMath.kt +90 -0
  31. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionGeometry.kt +160 -0
  32. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionRenderer.kt +1002 -0
  33. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/CompositionBuilder.kt +166 -0
  34. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ConfigurableInAppMp4Muxer.kt +137 -0
  35. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/EncoderFailureClassifier.kt +71 -0
  36. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/GifDecoder.kt +76 -0
  37. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/LayeredCompositionBuilder.kt +556 -0
  38. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/MediaInfoExtractor.kt +364 -0
  39. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ResilientVideoEncoderFactory.kt +307 -0
  40. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoCompositionTransformation.kt +192 -0
  41. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoEncoderConfig.kt +179 -0
  42. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoGlobalTrimCalculator.kt +147 -0
  43. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoReverser.kt +1044 -0
  44. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoSequenceBuilder.kt +905 -0
  45. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTimelineDurationCalculator.kt +19 -0
  46. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTranscoder.kt +242 -0
  47. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeAudioProcessor.kt +82 -0
  48. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeControlAudioMixer.kt +182 -0
  49. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/CodecResourceExhaustedException.kt +24 -0
  50. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderConfig.kt +572 -0
  51. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderJobHandle.kt +45 -0
  52. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/VideoEncoderConfigurationException.kt +20 -0
  53. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/RotatedVideoDimensions.kt +68 -0
  54. package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/VideoMimeUtils.kt +36 -0
  55. package/android/src/main/java/expo/modules/provideoeditor/src/shared/concurrency/ExportGate.kt +102 -0
  56. package/android/src/main/java/expo/modules/provideoeditor/src/shared/logging/PluginLog.kt +112 -0
  57. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/EncodedImage.kt +101 -0
  58. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/ImageOrientation.kt +265 -0
  59. package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/PcmRangeDecoder.kt +239 -0
  60. package/build/ExpoProVideoEditor.types.d.ts +125 -0
  61. package/build/ExpoProVideoEditor.types.d.ts.map +1 -0
  62. package/build/ExpoProVideoEditor.types.js +8 -0
  63. package/build/ExpoProVideoEditor.types.js.map +1 -0
  64. package/build/ExpoProVideoEditorModule.d.ts +20 -0
  65. package/build/ExpoProVideoEditorModule.d.ts.map +1 -0
  66. package/build/ExpoProVideoEditorModule.js +3 -0
  67. package/build/ExpoProVideoEditorModule.js.map +1 -0
  68. package/build/ExpoProVideoEditorModule.web.d.ts +9 -0
  69. package/build/ExpoProVideoEditorModule.web.d.ts.map +1 -0
  70. package/build/ExpoProVideoEditorModule.web.js +16 -0
  71. package/build/ExpoProVideoEditorModule.web.js.map +1 -0
  72. package/build/index.d.ts +3 -0
  73. package/build/index.d.ts.map +1 -0
  74. package/build/index.js +5 -0
  75. package/build/index.js.map +1 -0
  76. package/bun.lock +2132 -0
  77. package/eslint.config.cjs +5 -0
  78. package/expo-module.config.json +9 -0
  79. package/ios/ExpoProVideoEditor.podspec +24 -0
  80. package/ios/ExpoProVideoEditorModule.swift +131 -0
  81. package/ios/src/features/render/RenderVideo.swift +1087 -0
  82. package/ios/src/features/render/helpers/ApplyAnimation.swift +198 -0
  83. package/ios/src/features/render/helpers/ApplyBitrate.swift +112 -0
  84. package/ios/src/features/render/helpers/ApplyBlur.swift +23 -0
  85. package/ios/src/features/render/helpers/ApplyChromaKey.swift +175 -0
  86. package/ios/src/features/render/helpers/ApplyColorMatrix.swift +100 -0
  87. package/ios/src/features/render/helpers/ApplyComposition.swift +45 -0
  88. package/ios/src/features/render/helpers/ApplyCrop.swift +53 -0
  89. package/ios/src/features/render/helpers/ApplyFlip.swift +24 -0
  90. package/ios/src/features/render/helpers/ApplyImageLayer.swift +65 -0
  91. package/ios/src/features/render/helpers/ApplyPlaybackSpeed.swift +57 -0
  92. package/ios/src/features/render/helpers/ApplyRotation.swift +32 -0
  93. package/ios/src/features/render/helpers/ApplyScale.swift +30 -0
  94. package/ios/src/features/render/helpers/AudioPreRenderer.swift +352 -0
  95. package/ios/src/features/render/helpers/AudioReverser.swift +308 -0
  96. package/ios/src/features/render/helpers/AudioSequenceBuilder.swift +162 -0
  97. package/ios/src/features/render/helpers/BitrateCapPolicy.swift +77 -0
  98. package/ios/src/features/render/helpers/BitrateCappedExporter.swift +501 -0
  99. package/ios/src/features/render/helpers/ClipTransitionGeometry.swift +111 -0
  100. package/ios/src/features/render/helpers/ClipTransitionRenderer.swift +397 -0
  101. package/ios/src/features/render/helpers/CompositionBuilder.swift +464 -0
  102. package/ios/src/features/render/helpers/DecodeOrientedImage.swift +60 -0
  103. package/ios/src/features/render/helpers/LayeredCompositionBuilder.swift +371 -0
  104. package/ios/src/features/render/helpers/MediaInfoExtractor.swift +381 -0
  105. package/ios/src/features/render/helpers/TrackEndTrimmer.swift +112 -0
  106. package/ios/src/features/render/helpers/VideoSequenceBuilder.swift +555 -0
  107. package/ios/src/features/render/helpers/VideoTranscoder.swift +283 -0
  108. package/ios/src/features/render/models/RenderConfig.swift +586 -0
  109. package/ios/src/features/render/models/VideoClip.swift +81 -0
  110. package/ios/src/features/render/models/VideoCompositorConfig.swift +78 -0
  111. package/ios/src/features/render/utils/VideoCompositor.swift +1014 -0
  112. package/ios/src/features/render/utils/VideoMimeUtils.swift +24 -0
  113. package/ios/src/shared/EncodedImage.swift +75 -0
  114. package/ios/src/shared/ExportGate.swift +79 -0
  115. package/ios/src/shared/ExportSessionDriver.swift +185 -0
  116. package/ios/src/shared/ExportSessionGuard.swift +85 -0
  117. package/ios/src/shared/ExportWatchdog.swift +198 -0
  118. package/ios/src/shared/LoggingConstants.swift +7 -0
  119. package/ios/src/shared/PluginLog.swift +107 -0
  120. package/package.json +59 -0
  121. package/src/ExpoProVideoEditor.types.ts +148 -0
  122. package/src/ExpoProVideoEditorModule.ts +22 -0
  123. package/src/ExpoProVideoEditorModule.web.ts +22 -0
  124. package/src/index.ts +4 -0
  125. package/tsconfig.json +28 -0
@@ -0,0 +1,290 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import android.graphics.Bitmap
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.effect.BitmapOverlay
6
+ import androidx.media3.effect.StaticOverlaySettings
7
+ import expo.modules.provideoeditor.src.features.render.models.LayerAnimationConfig
8
+ import kotlin.math.max
9
+ import kotlin.math.min
10
+ import kotlin.math.pow
11
+ import kotlin.math.sin
12
+
13
+ /**
14
+ * Applies an easing function to a linear progress value (0..1).
15
+ */
16
+ internal fun applyEasing(t: Double, curve: String): Double {
17
+ return when (curve) {
18
+ "easeIn" -> t * t
19
+ "easeOut" -> t * (2 - t)
20
+ "easeInOut" -> if (t < 0.5) 2 * t * t else -1 + (4 - 2 * t) * t
21
+ "easeInCubic" -> t * t * t
22
+ "easeOutCubic" -> {
23
+ val p = 1 - t
24
+ 1 - p * p * p
25
+ }
26
+ "easeInOutCubic" -> if (t < 0.5) 4 * t * t * t else 1 - (-2 * t + 2).pow(3) / 2
27
+ "bounceIn" -> 1 - applyEasing(1 - t, "bounceOut")
28
+ "bounceOut" -> when {
29
+ t < 1 / 2.75 -> 7.5625 * t * t
30
+ t < 2 / 2.75 -> {
31
+ val t2 = t - 1.5 / 2.75
32
+ 7.5625 * t2 * t2 + 0.75
33
+ }
34
+ t < 2.5 / 2.75 -> {
35
+ val t2 = t - 2.25 / 2.75
36
+ 7.5625 * t2 * t2 + 0.9375
37
+ }
38
+ else -> {
39
+ val t2 = t - 2.625 / 2.75
40
+ 7.5625 * t2 * t2 + 0.984375
41
+ }
42
+ }
43
+ "bounceInOut" -> if (t < 0.5) {
44
+ (1 - applyEasing(1 - 2 * t, "bounceOut")) / 2
45
+ } else {
46
+ (1 + applyEasing(2 * t - 1, "bounceOut")) / 2
47
+ }
48
+ "elasticIn" -> 1 - applyEasing(1 - t, "elasticOut")
49
+ "elasticOut" -> {
50
+ if (t == 0.0 || t == 1.0) t
51
+ else 2.0.pow(-10 * t) * sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1
52
+ }
53
+ "elasticInOut" -> if (t < 0.5) {
54
+ (1 - applyEasing(1 - 2 * t, "elasticOut")) / 2
55
+ } else {
56
+ (1 + applyEasing(2 * t - 1, "elasticOut")) / 2
57
+ }
58
+ else -> t // "linear"
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Normalized slide offset (OpenGL coordinates, [-1, 1], +x right / +y up).
64
+ */
65
+ internal data class SlideOffset(val x: Float, val y: Float)
66
+
67
+ /**
68
+ * Computes the slide translation that moves a layer fully out of the canvas
69
+ * in [direction], edge-aware rather than layer-size-relative.
70
+ *
71
+ * At [invP] == 1 the layer's trailing edge sits exactly on the canvas edge in
72
+ * the slide direction (so the layer is just completely outside); at [invP] == 0
73
+ * the offset is zero (layer at rest). The canvas spans [-1, 1] on both axes.
74
+ *
75
+ * @param baseNormX Layer center X in [-1, 1] (+x right).
76
+ * @param baseNormY Layer center Y in [-1, 1] (+y up).
77
+ * @param halfNormW Layer half-width in [-1, 1] units (imageWidth / videoWidth).
78
+ * @param halfNormH Layer half-height in [-1, 1] units (imageHeight / videoHeight).
79
+ */
80
+ internal fun slideOffset(
81
+ direction: String?,
82
+ invP: Float,
83
+ baseNormX: Float,
84
+ baseNormY: Float,
85
+ halfNormW: Float,
86
+ halfNormH: Float,
87
+ ): SlideOffset = when (direction) {
88
+ "left" -> SlideOffset(invP * (-1f - baseNormX - halfNormW), 0f) // right edge → -1
89
+ "right" -> SlideOffset(invP * (1f - baseNormX + halfNormW), 0f) // left edge → +1
90
+ "top" -> SlideOffset(0f, invP * (1f - baseNormY + halfNormH)) // bottom edge → +1 (Y up)
91
+ "bottom" -> SlideOffset(0f, invP * (-1f - baseNormY - halfNormH)) // top edge → -1 (Y up)
92
+ else -> SlideOffset(0f, 0f)
93
+ }
94
+
95
+ /**
96
+ * A background-frame anchor paired with an overlay-frame anchor, both in the
97
+ * Media3 [-1, 1] range.
98
+ */
99
+ internal data class OverlayAnchors(
100
+ val backgroundAnchor: Float,
101
+ val overlayAnchor: Float,
102
+ )
103
+
104
+ /**
105
+ * Splits a desired layer-center position (in [-1, 1] NDC, possibly beyond the
106
+ * canvas to place the layer off-screen) into the two anchors Media3 accepts.
107
+ *
108
+ * Media3 clamps both [StaticOverlaySettings.Builder.setBackgroundFrameAnchor]
109
+ * and [StaticOverlaySettings.Builder.setOverlayFrameAnchor] to [-1, 1], so a
110
+ * single background anchor cannot move a layer fully off-screen. The background
111
+ * anchor covers the on-canvas part; the overlay anchor supplies the remaining
112
+ * off-canvas shift — its ±1 range maps to ±[halfNorm] of background travel,
113
+ * which is exactly one layer half-size, enough for an edge-flush slide-out.
114
+ *
115
+ * @param targetCenter Desired layer center on this axis (may exceed [-1, 1]).
116
+ * @param halfNorm Layer half-size on this axis in [-1, 1] units.
117
+ */
118
+ internal fun resolveAnchor(targetCenter: Float, halfNorm: Float): OverlayAnchors {
119
+ val background = targetCenter.coerceIn(-1f, 1f)
120
+ val overflow = targetCenter - background
121
+ // overlayCenter = background − overlayAnchor * halfNorm ⇒ solve for anchor.
122
+ val overlay = if (halfNorm > 0f) (-overflow / halfNorm).coerceIn(-1f, 1f) else 0f
123
+ return OverlayAnchors(background, overlay)
124
+ }
125
+
126
+ /**
127
+ * Custom BitmapOverlay that computes per-frame overlay settings for animations
128
+ * and, for animated images (GIF), returns the correct frame for the current
129
+ * presentation time.
130
+ *
131
+ * Uses [getOverlaySettings] to dynamically compute alpha, position offsets,
132
+ * and scale based on the current presentation time and animation configs.
133
+ *
134
+ * [frames] holds one bitmap for a static image, or several for an animated
135
+ * one; [frameDurationsUs] gives each frame's on-screen duration. All frames
136
+ * must share the same dimensions ([imageWidth] x [imageHeight]).
137
+ */
138
+ @UnstableApi
139
+ internal class AnimatedBitmapOverlay(
140
+ private val frames: List<Bitmap>,
141
+ private val frameDurationsUs: List<Long>,
142
+ private val baseNormX: Float,
143
+ private val baseNormY: Float,
144
+ private val imageWidth: Int,
145
+ private val imageHeight: Int,
146
+ private val videoWidth: Int,
147
+ private val videoHeight: Int,
148
+ private val layerStartUs: Long,
149
+ private val layerEndUs: Long,
150
+ private val loop: Boolean,
151
+ private val animations: List<LayerAnimationConfig>
152
+ ) : BitmapOverlay() {
153
+
154
+ /** Convenience constructor for a single static frame. */
155
+ constructor(
156
+ bitmap: Bitmap,
157
+ baseNormX: Float,
158
+ baseNormY: Float,
159
+ imageWidth: Int,
160
+ imageHeight: Int,
161
+ videoWidth: Int,
162
+ videoHeight: Int,
163
+ layerStartUs: Long,
164
+ layerEndUs: Long,
165
+ animations: List<LayerAnimationConfig>
166
+ ) : this(
167
+ frames = listOf(bitmap),
168
+ frameDurationsUs = listOf(0L),
169
+ baseNormX = baseNormX,
170
+ baseNormY = baseNormY,
171
+ imageWidth = imageWidth,
172
+ imageHeight = imageHeight,
173
+ videoWidth = videoWidth,
174
+ videoHeight = videoHeight,
175
+ layerStartUs = layerStartUs,
176
+ layerEndUs = layerEndUs,
177
+ loop = false,
178
+ animations = animations
179
+ )
180
+
181
+ // Cumulative end time of each frame within one playthrough, plus the total.
182
+ private val frameEndsUs: LongArray = LongArray(frames.size).also { ends ->
183
+ var acc = 0L
184
+ for (i in frames.indices) {
185
+ acc += frameDurationsUs[i]
186
+ ends[i] = acc
187
+ }
188
+ }
189
+ private val totalDurationUs: Long = frameEndsUs.lastOrNull() ?: 0L
190
+
191
+ override fun getBitmap(presentationTimeUs: Long): Bitmap {
192
+ if (frames.size == 1 || totalDurationUs <= 0L) return frames[0]
193
+
194
+ val effectiveStartUs = if (layerStartUs == -1L) 0L else layerStartUs
195
+ var t = presentationTimeUs - effectiveStartUs
196
+ if (t < 0L) t = 0L
197
+ t = if (loop) t % totalDurationUs else t.coerceAtMost(totalDurationUs - 1)
198
+
199
+ // frameEndsUs is ascending, so the first end strictly greater than t
200
+ // identifies the active frame.
201
+ for (i in frames.indices) {
202
+ if (t < frameEndsUs[i]) return frames[i]
203
+ }
204
+ return frames.last()
205
+ }
206
+
207
+ override fun getOverlaySettings(presentationTimeUs: Long): StaticOverlaySettings {
208
+ var alpha = 1.0f
209
+ var offsetX = 0f
210
+ var offsetY = 0f
211
+ var scaleVal = 1.0f
212
+
213
+ // Layer half-size in [-1, 1] units (canvas spans [-1, 1]).
214
+ val halfNormW = imageWidth.toFloat() / videoWidth
215
+ val halfNormH = imageHeight.toFloat() / videoHeight
216
+
217
+ val effectiveStartUs = if (layerStartUs == -1L) 0L else layerStartUs
218
+ val effectiveEndUs = if (layerEndUs == -1L) Long.MAX_VALUE else layerEndUs
219
+
220
+ for (anim in animations) {
221
+ val durationUs = anim.durationUs
222
+ if (durationUs <= 0) continue
223
+
224
+ // Determine progress for animateIn and/or animateOut
225
+ var inProgress: Double? = null
226
+ var outProgress: Double? = null
227
+
228
+ if (anim.phase == "animateIn" || anim.phase == "animateInOut") {
229
+ val elapsed = presentationTimeUs - effectiveStartUs
230
+ if (elapsed < durationUs) {
231
+ inProgress = applyEasing(
232
+ max(0.0, min(1.0, elapsed.toDouble() / durationUs)),
233
+ anim.curve
234
+ )
235
+ }
236
+ }
237
+
238
+ if (anim.phase == "animateOut" || anim.phase == "animateInOut") {
239
+ val remaining = effectiveEndUs - presentationTimeUs
240
+ if (remaining < durationUs) {
241
+ outProgress = applyEasing(
242
+ max(0.0, min(1.0, remaining.toDouble() / durationUs)),
243
+ anim.curve
244
+ )
245
+ }
246
+ }
247
+
248
+ // Use the minimum progress (most visible animation effect)
249
+ val progress: Double? = when {
250
+ inProgress != null && outProgress != null -> min(inProgress, outProgress)
251
+ else -> inProgress ?: outProgress
252
+ }
253
+
254
+ if (progress == null) continue
255
+
256
+ when (anim.type) {
257
+ "fade" -> alpha *= progress.toFloat()
258
+ "slide" -> {
259
+ val invP = (1.0 - progress).toFloat()
260
+ val off = slideOffset(
261
+ anim.slideDirection, invP,
262
+ baseNormX, baseNormY, halfNormW, halfNormH
263
+ )
264
+ offsetX += off.x
265
+ offsetY += off.y
266
+ }
267
+ "scale" -> {
268
+ val scaleFrom = anim.scaleFrom?.toFloat() ?: 0f
269
+ scaleVal *= scaleFrom + (1f - scaleFrom) * progress.toFloat()
270
+ }
271
+ }
272
+ }
273
+
274
+ // Clamp values — elastic/bounce curves can overshoot [0,1]
275
+ val clampedAlpha = alpha.coerceIn(0f, 1f)
276
+ val clampedScale = scaleVal.coerceAtLeast(0f)
277
+
278
+ // Media3 clamps each anchor to [-1, 1], so a fully off-screen slide is
279
+ // split across the background and overlay anchors (see resolveAnchor).
280
+ val anchorX = resolveAnchor(baseNormX + offsetX, halfNormW)
281
+ val anchorY = resolveAnchor(baseNormY + offsetY, halfNormH)
282
+
283
+ return StaticOverlaySettings.Builder()
284
+ .setAlphaScale(clampedAlpha)
285
+ .setBackgroundFrameAnchor(anchorX.backgroundAnchor, anchorY.backgroundAnchor)
286
+ .setOverlayFrameAnchor(anchorX.overlayAnchor, anchorY.overlayAnchor)
287
+ .setScale(clampedScale, clampedScale)
288
+ .build()
289
+ }
290
+ }
@@ -0,0 +1,110 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import android.media.MediaCodecInfo
4
+ import android.media.MediaCodecList
5
+ import androidx.media3.common.util.UnstableApi
6
+ import androidx.media3.transformer.DefaultEncoderFactory
7
+ import androidx.media3.transformer.VideoEncoderSettings
8
+ import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
9
+ import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
10
+
11
+ /**
12
+ * An encoder-safe bitrate configuration: a bitrate clamped to the codec's
13
+ * supported range plus a bitrate mode the codec actually accepts.
14
+ *
15
+ * @property bitrate Bitrate in bits per second.
16
+ * @property bitrateMode One of [MediaCodecInfo.EncoderCapabilities] BITRATE_MODE_*.
17
+ */
18
+ data class BitrateChoice(val bitrate: Int, val bitrateMode: Int)
19
+
20
+ /**
21
+ * Resolves an encoder-safe bitrate configuration for the given MIME type.
22
+ *
23
+ * - Clamps the requested bitrate into the codec's supported range
24
+ * - Uses CBR (Constant Bitrate) for predictable file size when supported and
25
+ * the bitrate is not in the upper portion of the range
26
+ * - Falls back to VBR (Variable Bitrate) when CBR is unsupported or the
27
+ * bitrate is high, since high-bitrate CBR is frequently rejected by
28
+ * hardware encoders
29
+ *
30
+ * @param mimeType Video MIME type (e.g., "video/avc" for H.264)
31
+ * @param bitrate Target bitrate in bits per second, or null to leave the
32
+ * encoder on its default bitrate.
33
+ * @return The resolved [BitrateChoice], or null when no bitrate was requested or
34
+ * no encoder could be found for [mimeType].
35
+ */
36
+ @UnstableApi
37
+ fun resolveBitrateSettings(mimeType: String?, bitrate: Int?): BitrateChoice? {
38
+ if (bitrate == null) return null
39
+ Log.d(RENDER_TAG, "Configuring bitrate: ${bitrate / 1000} kbps")
40
+
41
+ val codecInfo = MediaCodecList(MediaCodecList.ALL_CODECS)
42
+ .codecInfos
43
+ .firstOrNull { it.isEncoder && it.supportedTypes.contains(mimeType) }
44
+
45
+ if (codecInfo == null) {
46
+ Log.e(RENDER_TAG, "No encoder found for $mimeType")
47
+ return null
48
+ }
49
+
50
+ val capabilities = codecInfo.getCapabilitiesForType(mimeType)
51
+ val bitrateRange = capabilities.videoCapabilities.bitrateRange
52
+ val supportsCBR = capabilities.encoderCapabilities
53
+ .isBitrateModeSupported(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
54
+
55
+ // Clamp the requested bitrate into the supported range instead of bailing
56
+ // out. Bailing left the encoder on its (often very high) default bitrate,
57
+ // which is one of the configurations Qualcomm encoders reject outright.
58
+ val safeBitrate = bitrate.coerceIn(bitrateRange.lower, bitrateRange.upper)
59
+ if (safeBitrate != bitrate) {
60
+ Log.w(
61
+ RENDER_TAG,
62
+ "Bitrate ${bitrate / 1000} kbps outside supported range " +
63
+ "${bitrateRange.lower / 1000}-${bitrateRange.upper / 1000} kbps, " +
64
+ "clamping to ${safeBitrate / 1000} kbps"
65
+ )
66
+ }
67
+
68
+ // CBR at a high bitrate (relative to the codec's max) is the combination
69
+ // most likely to be rejected by hardware encoders (e.g. Qualcomm AVC at
70
+ // 1080p with High profile). When the requested bitrate sits in the upper
71
+ // portion of the supported range, prefer VBR which is far more forgiving.
72
+ val highBitrateThreshold = (bitrateRange.upper * 0.8).toInt()
73
+ val useCBR = supportsCBR && safeBitrate <= highBitrateThreshold
74
+
75
+ val bitrateMode = if (useCBR) {
76
+ Log.d(RENDER_TAG, "Using CBR (Constant Bitrate) mode")
77
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
78
+ } else {
79
+ val reason = if (!supportsCBR) "CBR not supported" else "high bitrate, avoiding CBR"
80
+ Log.d(RENDER_TAG, "Using VBR (Variable Bitrate) mode ($reason)")
81
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR
82
+ }
83
+
84
+ return BitrateChoice(safeBitrate, bitrateMode)
85
+ }
86
+
87
+ /**
88
+ * Configures video encoder bitrate settings on [encoderFactoryBuilder].
89
+ *
90
+ * Thin wrapper around [resolveBitrateSettings] kept for callers that
91
+ * configure a [DefaultEncoderFactory.Builder] directly.
92
+ *
93
+ * @param encoderFactoryBuilder Encoder factory to configure
94
+ * @param mimeType Video MIME type (e.g., "video/avc" for H.264)
95
+ * @param bitrate Target bitrate in bits per second
96
+ */
97
+ @UnstableApi
98
+ fun applyBitrate(
99
+ encoderFactoryBuilder: DefaultEncoderFactory.Builder,
100
+ mimeType: String?,
101
+ bitrate: Int?
102
+ ) {
103
+ val choice = resolveBitrateSettings(mimeType, bitrate) ?: return
104
+ encoderFactoryBuilder.setRequestedVideoEncoderSettings(
105
+ VideoEncoderSettings.Builder()
106
+ .setBitrateMode(choice.bitrateMode)
107
+ .setBitrate(choice.bitrate)
108
+ .build()
109
+ )
110
+ }
@@ -0,0 +1,27 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import androidx.media3.common.Effect
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.effect.GaussianBlur
6
+ import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
7
+ import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
8
+
9
+ /**
10
+ * Applies Gaussian blur effect to video.
11
+ *
12
+ * Uses GaussianBlur with sigma multiplied by 2.5 for visual consistency.
13
+ * No effect if blur value is null or <= 0.
14
+ *
15
+ * @param videoEffects List to add blur effect to
16
+ * @param blur Blur intensity (higher = more blur)
17
+ */
18
+ @UnstableApi
19
+ fun applyBlur(videoEffects: MutableList<Effect>, blur: Double?) {
20
+ if (blur == null || blur <= 0.0) return
21
+
22
+ val actualSigma = blur.toFloat() * 2.5f
23
+ Log.d(RENDER_TAG, "Applying Gaussian blur: intensity=$blur, sigma=$actualSigma")
24
+
25
+ val blurEffect = GaussianBlur(blur.toFloat() * 2.5f)
26
+ videoEffects += blurEffect
27
+ }
@@ -0,0 +1,58 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import androidx.media3.common.Effect
4
+ import androidx.media3.common.util.UnstableApi
5
+ import expo.modules.provideoeditor.src.core.constants.RENDER_TAG
6
+ import expo.modules.provideoeditor.src.features.render.models.ChromaKeyConfig
7
+ import expo.modules.provideoeditor.src.shared.logging.PluginLog as Log
8
+
9
+ /**
10
+ * Adds a chroma key ("green screen" removal) to a clip's effect chain.
11
+ *
12
+ * Must be added **before** rotation, flip, the color LUT and blur, so the key
13
+ * sees the original decoded colors. Media3's LUT shader preserves alpha
14
+ * (`gl_FragColor.a = inputColor.a`), so a color filter after the key is safe.
15
+ *
16
+ * @param videoEffects List to add the effect to
17
+ * @param config The resolved key (clip ?: layer ?: global), or null for none
18
+ * @param flattenTransparency Whether a key without a background should be
19
+ * filled with opaque black instead of left transparent. True on the
20
+ * single-track path — see below.
21
+ */
22
+ @UnstableApi
23
+ fun applyChromaKey(
24
+ videoEffects: MutableList<Effect>,
25
+ config: ChromaKeyConfig?,
26
+ flattenTransparency: Boolean = false,
27
+ ) {
28
+ if (config == null) return
29
+
30
+ // H.264/HEVC carry no alpha channel, and the single-track path has no layer
31
+ // underneath to blend into, so "transparent" has no meaning there. Left
32
+ // alone the two platforms would disagree visibly: Apple's premultiplied
33
+ // color cube writes rgb*0 and the area comes out black, while this shader
34
+ // writes straight alpha and leaves the RGB untouched, so the screen would
35
+ // simply stay green — "nothing happened". Substituting opaque black makes
36
+ // both platforms produce the documented result.
37
+ val effective = if (flattenTransparency && config.isTransparent) {
38
+ config.copy(backgroundColor = OPAQUE_BLACK)
39
+ } else {
40
+ config
41
+ }
42
+
43
+ val background = when {
44
+ effective.backgroundImage != null -> "image"
45
+ effective.backgroundColor != null -> "color"
46
+ else -> "transparent"
47
+ }
48
+ Log.d(
49
+ RENDER_TAG,
50
+ "Applying chroma key: similarity=${effective.similarity} " +
51
+ "smoothness=${effective.smoothness} spill=${effective.spill} " +
52
+ "background=$background"
53
+ )
54
+
55
+ videoEffects += ChromaKeyEffect(effective)
56
+ }
57
+
58
+ private const val OPAQUE_BLACK = 0xFF000000.toInt()
@@ -0,0 +1,88 @@
1
+ package expo.modules.provideoeditor.src.features.render.helpers
2
+
3
+ import android.graphics.Bitmap
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.effect.BitmapOverlay
6
+ import androidx.media3.effect.StaticOverlaySettings
7
+ import kotlin.math.max
8
+
9
+ /**
10
+ * Full-frame solid-color overlay used to implement "dip" clip transitions
11
+ * (fade-to-black / fade-to-white).
12
+ *
13
+ * A single instance is attached to a clip's effect pipeline. The overlay alpha
14
+ * is computed per frame from the clip-local presentation time:
15
+ * - [fadeInUs] > 0: the clip fades **in from the dip color** over its first
16
+ * window (alpha 1 → 0).
17
+ * - [fadeOutUs] > 0: the clip fades **out to the dip color** over its last
18
+ * window (alpha 0 → 1).
19
+ *
20
+ * Timestamps are clip-local (each [androidx.media3.transformer.EditedMediaItem]
21
+ * in a sequence is processed with its own timeline), and the overlay is added
22
+ * **after** any per-clip [androidx.media3.effect.SpeedChangeEffect] so the
23
+ * windows are expressed in output-local time via [clipDurationUs].
24
+ *
25
+ * @param dipColor ARGB color the clip dips to/from (e.g. black or white).
26
+ */
27
+ @UnstableApi
28
+ internal class ClipFadeOverlay(
29
+ videoWidth: Int,
30
+ videoHeight: Int,
31
+ dipColor: Int,
32
+ private val clipDurationUs: Long,
33
+ private val fadeInUs: Long,
34
+ private val fadeOutUs: Long,
35
+ private val curve: String,
36
+ ) : BitmapOverlay() {
37
+
38
+ private val dipBitmap: Bitmap =
39
+ Bitmap.createBitmap(
40
+ max(1, videoWidth),
41
+ max(1, videoHeight),
42
+ Bitmap.Config.ARGB_8888,
43
+ ).apply { eraseColor(dipColor) }
44
+
45
+ private val settingsBuilder = StaticOverlaySettings.Builder()
46
+ .setOverlayFrameAnchor(0f, 0f)
47
+ .setBackgroundFrameAnchor(0f, 0f)
48
+
49
+ /**
50
+ * Smallest presentation timestamp seen so far. Media3 does **not** present
51
+ * clip-local 0-based timestamps to per-clip effects (a trimmed/non-first
52
+ * clip starts at its own offset), so we normalize against the first frame
53
+ * the overlay sees. This makes the windows clip-relative regardless of the
54
+ * underlying timestamp domain (source-based or cumulative).
55
+ */
56
+ private var firstTimeUs = Long.MIN_VALUE
57
+
58
+ override fun getBitmap(presentationTimeUs: Long): Bitmap = dipBitmap
59
+
60
+ override fun getOverlaySettings(presentationTimeUs: Long): StaticOverlaySettings {
61
+ if (firstTimeUs == Long.MIN_VALUE || presentationTimeUs < firstTimeUs) {
62
+ firstTimeUs = presentationTimeUs
63
+ }
64
+ val localUs = (presentationTimeUs - firstTimeUs).coerceAtLeast(0L)
65
+
66
+ var alpha = 0f
67
+
68
+ if (fadeInUs > 0 && localUs < fadeInUs) {
69
+ val p = (localUs.toDouble() / fadeInUs).coerceIn(0.0, 1.0)
70
+ // Fade in from black: opaque at start, transparent at the window end.
71
+ alpha = (1.0 - applyEasing(p, curve)).toFloat()
72
+ }
73
+
74
+ if (fadeOutUs > 0) {
75
+ val outStart = clipDurationUs - fadeOutUs
76
+ if (localUs > outStart) {
77
+ val p = ((localUs - outStart).toDouble() / fadeOutUs)
78
+ .coerceIn(0.0, 1.0)
79
+ // Fade out to black: transparent at the window start, opaque at end.
80
+ alpha = max(alpha, applyEasing(p, curve).toFloat())
81
+ }
82
+ }
83
+
84
+ return settingsBuilder
85
+ .setAlphaScale(alpha.coerceIn(0f, 1f))
86
+ .build()
87
+ }
88
+ }