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.
- package/.prettierrc +8 -0
- package/LICENSE +67 -0
- package/NOTICE.md +250 -0
- package/README.md +70 -0
- package/android/build.gradle +35 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/provideoeditor/ExpoProVideoEditorModule.kt +163 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/core/constants/LoggingConstants.kt +4 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/EffectsProcessor.kt +66 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/RenderVideo.kt +1089 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyAnimation.kt +290 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBitrate.kt +110 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt +27 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyChromaKey.kt +58 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyClipTransition.kt +88 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyColorMatrix.kt +144 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyComposition.kt +86 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyCrop.kt +104 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFlip.kt +27 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyFrameRate.kt +28 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyImageLayer.kt +397 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyOpacity.kt +24 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyPlaybackSpeed.kt +35 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyRotation.kt +26 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyScale.kt +29 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioPreRenderer.kt +492 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/AudioSequenceBuilder.kt +144 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/BitrateCapPolicy.kt +45 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyEffect.kt +321 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ChromaKeyMath.kt +90 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionGeometry.kt +160 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ClipTransitionRenderer.kt +1002 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/CompositionBuilder.kt +166 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ConfigurableInAppMp4Muxer.kt +137 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/EncoderFailureClassifier.kt +71 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/GifDecoder.kt +76 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/LayeredCompositionBuilder.kt +556 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/MediaInfoExtractor.kt +364 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ResilientVideoEncoderFactory.kt +307 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoCompositionTransformation.kt +192 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoEncoderConfig.kt +179 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoGlobalTrimCalculator.kt +147 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoReverser.kt +1044 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoSequenceBuilder.kt +905 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTimelineDurationCalculator.kt +19 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VideoTranscoder.kt +242 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeAudioProcessor.kt +82 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/VolumeControlAudioMixer.kt +182 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/CodecResourceExhaustedException.kt +24 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderConfig.kt +572 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/RenderJobHandle.kt +45 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/models/VideoEncoderConfigurationException.kt +20 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/RotatedVideoDimensions.kt +68 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/features/render/utils/VideoMimeUtils.kt +36 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/concurrency/ExportGate.kt +102 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/logging/PluginLog.kt +112 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/EncodedImage.kt +101 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/ImageOrientation.kt +265 -0
- package/android/src/main/java/expo/modules/provideoeditor/src/shared/media/PcmRangeDecoder.kt +239 -0
- package/build/ExpoProVideoEditor.types.d.ts +125 -0
- package/build/ExpoProVideoEditor.types.d.ts.map +1 -0
- package/build/ExpoProVideoEditor.types.js +8 -0
- package/build/ExpoProVideoEditor.types.js.map +1 -0
- package/build/ExpoProVideoEditorModule.d.ts +20 -0
- package/build/ExpoProVideoEditorModule.d.ts.map +1 -0
- package/build/ExpoProVideoEditorModule.js +3 -0
- package/build/ExpoProVideoEditorModule.js.map +1 -0
- package/build/ExpoProVideoEditorModule.web.d.ts +9 -0
- package/build/ExpoProVideoEditorModule.web.d.ts.map +1 -0
- package/build/ExpoProVideoEditorModule.web.js +16 -0
- package/build/ExpoProVideoEditorModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/bun.lock +2132 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoProVideoEditor.podspec +24 -0
- package/ios/ExpoProVideoEditorModule.swift +131 -0
- package/ios/src/features/render/RenderVideo.swift +1087 -0
- package/ios/src/features/render/helpers/ApplyAnimation.swift +198 -0
- package/ios/src/features/render/helpers/ApplyBitrate.swift +112 -0
- package/ios/src/features/render/helpers/ApplyBlur.swift +23 -0
- package/ios/src/features/render/helpers/ApplyChromaKey.swift +175 -0
- package/ios/src/features/render/helpers/ApplyColorMatrix.swift +100 -0
- package/ios/src/features/render/helpers/ApplyComposition.swift +45 -0
- package/ios/src/features/render/helpers/ApplyCrop.swift +53 -0
- package/ios/src/features/render/helpers/ApplyFlip.swift +24 -0
- package/ios/src/features/render/helpers/ApplyImageLayer.swift +65 -0
- package/ios/src/features/render/helpers/ApplyPlaybackSpeed.swift +57 -0
- package/ios/src/features/render/helpers/ApplyRotation.swift +32 -0
- package/ios/src/features/render/helpers/ApplyScale.swift +30 -0
- package/ios/src/features/render/helpers/AudioPreRenderer.swift +352 -0
- package/ios/src/features/render/helpers/AudioReverser.swift +308 -0
- package/ios/src/features/render/helpers/AudioSequenceBuilder.swift +162 -0
- package/ios/src/features/render/helpers/BitrateCapPolicy.swift +77 -0
- package/ios/src/features/render/helpers/BitrateCappedExporter.swift +501 -0
- package/ios/src/features/render/helpers/ClipTransitionGeometry.swift +111 -0
- package/ios/src/features/render/helpers/ClipTransitionRenderer.swift +397 -0
- package/ios/src/features/render/helpers/CompositionBuilder.swift +464 -0
- package/ios/src/features/render/helpers/DecodeOrientedImage.swift +60 -0
- package/ios/src/features/render/helpers/LayeredCompositionBuilder.swift +371 -0
- package/ios/src/features/render/helpers/MediaInfoExtractor.swift +381 -0
- package/ios/src/features/render/helpers/TrackEndTrimmer.swift +112 -0
- package/ios/src/features/render/helpers/VideoSequenceBuilder.swift +555 -0
- package/ios/src/features/render/helpers/VideoTranscoder.swift +283 -0
- package/ios/src/features/render/models/RenderConfig.swift +586 -0
- package/ios/src/features/render/models/VideoClip.swift +81 -0
- package/ios/src/features/render/models/VideoCompositorConfig.swift +78 -0
- package/ios/src/features/render/utils/VideoCompositor.swift +1014 -0
- package/ios/src/features/render/utils/VideoMimeUtils.swift +24 -0
- package/ios/src/shared/EncodedImage.swift +75 -0
- package/ios/src/shared/ExportGate.swift +79 -0
- package/ios/src/shared/ExportSessionDriver.swift +185 -0
- package/ios/src/shared/ExportSessionGuard.swift +85 -0
- package/ios/src/shared/ExportWatchdog.swift +198 -0
- package/ios/src/shared/LoggingConstants.swift +7 -0
- package/ios/src/shared/PluginLog.swift +107 -0
- package/package.json +59 -0
- package/src/ExpoProVideoEditor.types.ts +148 -0
- package/src/ExpoProVideoEditorModule.ts +22 -0
- package/src/ExpoProVideoEditorModule.web.ts +22 -0
- package/src/index.ts +4 -0
- 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
|
+
}
|
package/android/src/main/java/expo/modules/provideoeditor/src/features/render/helpers/ApplyBlur.kt
ADDED
|
@@ -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
|
+
}
|