react-native-transparent-video-player 0.3.2
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/LICENSE +21 -0
- package/README.md +29 -0
- package/THIRD-PARTY-NOTICES.md +24 -0
- package/android/build.gradle +26 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/alphamovie/lib/GLTextureView.java +1825 -0
- package/android/src/main/java/expo/modules/transparentvideo/TransparentVideoModule.kt +30 -0
- package/android/src/main/java/expo/modules/transparentvideo/TransparentVideoRenderer.kt +246 -0
- package/android/src/main/java/expo/modules/transparentvideo/TransparentVideoView.kt +101 -0
- package/cli/pack-transparent-video.mjs +247 -0
- package/expo-module.config.json +6 -0
- package/lib/TransparentVideo.d.ts +33 -0
- package/lib/TransparentVideo.js +17 -0
- package/lib/TransparentVideoView.android.d.ts +8 -0
- package/lib/TransparentVideoView.android.js +28 -0
- package/lib/TransparentVideoView.d.ts +8 -0
- package/lib/TransparentVideoView.js +43 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +5 -0
- package/lib/types.d.ts +19 -0
- package/lib/types.js +2 -0
- package/lib/useResolvedUri.d.ts +7 -0
- package/lib/useResolvedUri.js +50 -0
- package/package.json +67 -0
- package/src/TransparentVideo.tsx +60 -0
- package/src/TransparentVideoView.android.tsx +61 -0
- package/src/TransparentVideoView.tsx +77 -0
- package/src/index.ts +2 -0
- package/src/types.ts +20 -0
- package/src/useResolvedUri.ts +60 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package expo.modules.transparentvideo
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class TransparentVideoModule : Module() {
|
|
7
|
+
override fun definition() = ModuleDefinition {
|
|
8
|
+
Name("TransparentVideo")
|
|
9
|
+
|
|
10
|
+
View(TransparentVideoView::class) {
|
|
11
|
+
Events("onVideoEnd")
|
|
12
|
+
|
|
13
|
+
Prop("sourceUri") { view: TransparentVideoView, uri: String? ->
|
|
14
|
+
view.setSource(uri)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Prop("loop") { view: TransparentVideoView, loop: Boolean ->
|
|
18
|
+
view.loop = loop
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Prop("paused") { view: TransparentVideoView, paused: Boolean ->
|
|
22
|
+
view.paused = paused
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
OnViewDestroys { view: TransparentVideoView ->
|
|
26
|
+
view.release()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Derived from alpha-movie's VideoRenderer
|
|
3
|
+
* (https://github.com/pavelsiamak/alpha-movie, Copyright 2017 Pavel Semak),
|
|
4
|
+
* licensed under the Apache License, Version 2.0:
|
|
5
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Modifications for react-native-transparent-video-skia:
|
|
8
|
+
* - Kotlin port, packed top-color/bottom-alpha shader (after
|
|
9
|
+
* status-im/react-native-transparent-video, MIT)
|
|
10
|
+
* - premultiplied-alpha output: blending disabled, rgb passed through as-is
|
|
11
|
+
* - Surface handed to the host view via callback instead of a listener chain
|
|
12
|
+
*/
|
|
13
|
+
package expo.modules.transparentvideo
|
|
14
|
+
|
|
15
|
+
import android.graphics.SurfaceTexture
|
|
16
|
+
import android.opengl.GLES20
|
|
17
|
+
import android.opengl.Matrix
|
|
18
|
+
import android.view.Surface
|
|
19
|
+
import com.alphamovie.lib.GLTextureView
|
|
20
|
+
import java.nio.ByteBuffer
|
|
21
|
+
import java.nio.ByteOrder
|
|
22
|
+
import java.nio.FloatBuffer
|
|
23
|
+
import javax.microedition.khronos.egl.EGLConfig
|
|
24
|
+
import javax.microedition.khronos.opengles.GL10
|
|
25
|
+
|
|
26
|
+
private const val FLOAT_SIZE_BYTES = 4
|
|
27
|
+
private const val VERTICES_STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
|
|
28
|
+
private const val VERTICES_POS_OFFSET = 0
|
|
29
|
+
private const val VERTICES_UV_OFFSET = 3
|
|
30
|
+
private const val GL_TEXTURE_EXTERNAL_OES = 0x8D65
|
|
31
|
+
|
|
32
|
+
// The halves-split must happen in CONTENT space and only then go through the
|
|
33
|
+
// SurfaceTexture transform (uSTMatrix): decoders pad the buffer to 16-row
|
|
34
|
+
// alignment (e.g. 1800 -> 1808), so the matrix carries a crop scale — applying
|
|
35
|
+
// the split after the transform misaligns the alpha matte by several pixels.
|
|
36
|
+
private const val VERTEX_SHADER = """
|
|
37
|
+
uniform mat4 uMVPMatrix;
|
|
38
|
+
uniform mat4 uSTMatrix;
|
|
39
|
+
attribute vec4 aPosition;
|
|
40
|
+
attribute vec4 aTextureCoord;
|
|
41
|
+
varying vec2 vColorCoord;
|
|
42
|
+
varying vec2 vAlphaCoord;
|
|
43
|
+
void main() {
|
|
44
|
+
gl_Position = uMVPMatrix * aPosition;
|
|
45
|
+
// SurfaceTexture content space: t=0 is the image BOTTOM. The packed video
|
|
46
|
+
// shows color on top (t in [0.5, 1]) and the alpha matte below (t in [0, 0.5]).
|
|
47
|
+
vColorCoord = (uSTMatrix * vec4(aTextureCoord.x, 0.5 + aTextureCoord.y * 0.5, 0.0, 1.0)).xy;
|
|
48
|
+
vAlphaCoord = (uSTMatrix * vec4(aTextureCoord.x, aTextureCoord.y * 0.5, 0.0, 1.0)).xy;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
// Packed layout: premultiplied color in the top half, alpha matte in the
|
|
53
|
+
// bottom half (content space). rgb is ALREADY premultiplied by the
|
|
54
|
+
// pack-alpha-video CLI — pass it through; multiplying by alpha again
|
|
55
|
+
// would darken antialiased edges.
|
|
56
|
+
private const val FRAGMENT_SHADER = """
|
|
57
|
+
#extension GL_OES_EGL_image_external : require
|
|
58
|
+
precision mediump float;
|
|
59
|
+
varying vec2 vColorCoord;
|
|
60
|
+
varying vec2 vAlphaCoord;
|
|
61
|
+
uniform samplerExternalOES sTexture;
|
|
62
|
+
void main() {
|
|
63
|
+
vec4 color = texture2D(sTexture, vColorCoord);
|
|
64
|
+
float alpha = texture2D(sTexture, vAlphaCoord).r;
|
|
65
|
+
gl_FragColor = vec4(color.rgb, alpha);
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
class TransparentVideoRenderer : GLTextureView.Renderer {
|
|
70
|
+
|
|
71
|
+
/** Called on the GL thread whenever a new decoder frame is ready. */
|
|
72
|
+
var onRequestRender: (() -> Unit)? = null
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called on the GL thread with a Surface wrapping the freshly created
|
|
76
|
+
* SurfaceTexture. Fires again after every EGL surface recreation
|
|
77
|
+
* (view detach/reattach) — the host must re-bind it to the player.
|
|
78
|
+
*/
|
|
79
|
+
var onSurfaceReady: ((Surface) -> Unit)? = null
|
|
80
|
+
|
|
81
|
+
private val triangleVerticesData = floatArrayOf(
|
|
82
|
+
// x, y, z, u, v
|
|
83
|
+
-1.0f, -1.0f, 0f, 0f, 0f,
|
|
84
|
+
1.0f, -1.0f, 0f, 1f, 0f,
|
|
85
|
+
-1.0f, 1.0f, 0f, 0f, 1f,
|
|
86
|
+
1.0f, 1.0f, 0f, 1f, 1f,
|
|
87
|
+
)
|
|
88
|
+
private val triangleVertices: FloatBuffer = ByteBuffer
|
|
89
|
+
.allocateDirect(triangleVerticesData.size * FLOAT_SIZE_BYTES)
|
|
90
|
+
.order(ByteOrder.nativeOrder())
|
|
91
|
+
.asFloatBuffer()
|
|
92
|
+
.apply { put(triangleVerticesData).position(0) }
|
|
93
|
+
|
|
94
|
+
private val mvpMatrix = FloatArray(16)
|
|
95
|
+
private val stMatrix = FloatArray(16)
|
|
96
|
+
|
|
97
|
+
private var program = 0
|
|
98
|
+
private var textureId = 0
|
|
99
|
+
private var mvpMatrixHandle = 0
|
|
100
|
+
private var stMatrixHandle = 0
|
|
101
|
+
private var positionHandle = 0
|
|
102
|
+
private var textureCoordHandle = 0
|
|
103
|
+
|
|
104
|
+
private var surfaceTexture: SurfaceTexture? = null
|
|
105
|
+
|
|
106
|
+
@Volatile
|
|
107
|
+
private var frameAvailable = false
|
|
108
|
+
private val frameLock = Any()
|
|
109
|
+
|
|
110
|
+
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
|
111
|
+
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
|
112
|
+
check(program != 0) { "TransparentVideoRenderer: failed to create GL program" }
|
|
113
|
+
|
|
114
|
+
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
|
115
|
+
textureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
|
|
116
|
+
mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
|
|
117
|
+
stMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
|
|
118
|
+
|
|
119
|
+
val textures = IntArray(1)
|
|
120
|
+
GLES20.glGenTextures(1, textures, 0)
|
|
121
|
+
textureId = textures[0]
|
|
122
|
+
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, textureId)
|
|
123
|
+
GLES20.glTexParameterf(
|
|
124
|
+
GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
|
|
125
|
+
)
|
|
126
|
+
GLES20.glTexParameterf(
|
|
127
|
+
GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
|
|
128
|
+
)
|
|
129
|
+
GLES20.glTexParameteri(
|
|
130
|
+
GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
|
|
131
|
+
)
|
|
132
|
+
GLES20.glTexParameteri(
|
|
133
|
+
GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
Matrix.setIdentityM(stMatrix, 0)
|
|
137
|
+
Matrix.setIdentityM(mvpMatrix, 0)
|
|
138
|
+
|
|
139
|
+
// Release any previous SurfaceTexture (EGL context was lost/recreated).
|
|
140
|
+
surfaceTexture?.release()
|
|
141
|
+
val st = SurfaceTexture(textureId)
|
|
142
|
+
st.setOnFrameAvailableListener {
|
|
143
|
+
synchronized(frameLock) { frameAvailable = true }
|
|
144
|
+
onRequestRender?.invoke()
|
|
145
|
+
}
|
|
146
|
+
surfaceTexture = st
|
|
147
|
+
|
|
148
|
+
onSurfaceReady?.invoke(Surface(st))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
|
152
|
+
GLES20.glViewport(0, 0, width, height)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// GLTextureView extension over stock GLSurfaceView.Renderer: called on the
|
|
156
|
+
// GL thread when the EGL surface goes away (view detach). The SurfaceTexture
|
|
157
|
+
// belongs to the dying GL context; a fresh one is created in the next
|
|
158
|
+
// onSurfaceCreated and re-handed to the player via onSurfaceReady.
|
|
159
|
+
override fun onSurfaceDestroyed(gl: GL10?) {
|
|
160
|
+
synchronized(frameLock) { frameAvailable = false }
|
|
161
|
+
surfaceTexture?.release()
|
|
162
|
+
surfaceTexture = null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override fun onDrawFrame(gl: GL10?) {
|
|
166
|
+
synchronized(frameLock) {
|
|
167
|
+
if (frameAvailable) {
|
|
168
|
+
surfaceTexture?.updateTexImage()
|
|
169
|
+
surfaceTexture?.getTransformMatrix(stMatrix)
|
|
170
|
+
frameAvailable = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Transparent black is valid premultiplied "nothing".
|
|
175
|
+
GLES20.glClearColor(0f, 0f, 0f, 0f)
|
|
176
|
+
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
|
|
177
|
+
|
|
178
|
+
// Output is premultiplied and the quad covers the whole (cleared)
|
|
179
|
+
// surface — no in-surface blending needed. The Android compositor
|
|
180
|
+
// blends the TextureView's premultiplied pixels with what's behind it.
|
|
181
|
+
GLES20.glDisable(GLES20.GL_BLEND)
|
|
182
|
+
|
|
183
|
+
GLES20.glUseProgram(program)
|
|
184
|
+
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
|
185
|
+
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, textureId)
|
|
186
|
+
|
|
187
|
+
triangleVertices.position(VERTICES_POS_OFFSET)
|
|
188
|
+
GLES20.glVertexAttribPointer(
|
|
189
|
+
positionHandle, 3, GLES20.GL_FLOAT, false, VERTICES_STRIDE_BYTES, triangleVertices
|
|
190
|
+
)
|
|
191
|
+
GLES20.glEnableVertexAttribArray(positionHandle)
|
|
192
|
+
|
|
193
|
+
triangleVertices.position(VERTICES_UV_OFFSET)
|
|
194
|
+
GLES20.glVertexAttribPointer(
|
|
195
|
+
textureCoordHandle, 2, GLES20.GL_FLOAT, false, VERTICES_STRIDE_BYTES, triangleVertices
|
|
196
|
+
)
|
|
197
|
+
GLES20.glEnableVertexAttribArray(textureCoordHandle)
|
|
198
|
+
|
|
199
|
+
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0)
|
|
200
|
+
GLES20.glUniformMatrix4fv(stMatrixHandle, 1, false, stMatrix, 0)
|
|
201
|
+
|
|
202
|
+
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
|
203
|
+
GLES20.glFinish()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fun release() {
|
|
207
|
+
surfaceTexture?.release()
|
|
208
|
+
surfaceTexture = null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
|
|
212
|
+
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
|
213
|
+
if (vertexShader == 0) return 0
|
|
214
|
+
val pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
|
215
|
+
if (pixelShader == 0) return 0
|
|
216
|
+
|
|
217
|
+
var program = GLES20.glCreateProgram()
|
|
218
|
+
if (program == 0) return 0
|
|
219
|
+
GLES20.glAttachShader(program, vertexShader)
|
|
220
|
+
GLES20.glAttachShader(program, pixelShader)
|
|
221
|
+
GLES20.glLinkProgram(program)
|
|
222
|
+
val linkStatus = IntArray(1)
|
|
223
|
+
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
|
224
|
+
if (linkStatus[0] != GLES20.GL_TRUE) {
|
|
225
|
+
android.util.Log.e("TransparentVideo", "Could not link program: " + GLES20.glGetProgramInfoLog(program))
|
|
226
|
+
GLES20.glDeleteProgram(program)
|
|
227
|
+
program = 0
|
|
228
|
+
}
|
|
229
|
+
return program
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private fun loadShader(shaderType: Int, source: String): Int {
|
|
233
|
+
var shader = GLES20.glCreateShader(shaderType)
|
|
234
|
+
if (shader == 0) return 0
|
|
235
|
+
GLES20.glShaderSource(shader, source)
|
|
236
|
+
GLES20.glCompileShader(shader)
|
|
237
|
+
val compiled = IntArray(1)
|
|
238
|
+
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
|
|
239
|
+
if (compiled[0] == 0) {
|
|
240
|
+
android.util.Log.e("TransparentVideo", "Could not compile shader $shaderType: " + GLES20.glGetShaderInfoLog(shader))
|
|
241
|
+
GLES20.glDeleteShader(shader)
|
|
242
|
+
shader = 0
|
|
243
|
+
}
|
|
244
|
+
return shader
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package expo.modules.transparentvideo
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.Surface
|
|
5
|
+
import androidx.media3.common.AudioAttributes
|
|
6
|
+
import androidx.media3.common.MediaItem
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
9
|
+
import expo.modules.kotlin.AppContext
|
|
10
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
11
|
+
import expo.modules.kotlin.views.ExpoView
|
|
12
|
+
import com.alphamovie.lib.GLTextureView
|
|
13
|
+
|
|
14
|
+
class TransparentVideoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
15
|
+
private val onVideoEnd by EventDispatcher()
|
|
16
|
+
|
|
17
|
+
private val renderer = TransparentVideoRenderer()
|
|
18
|
+
private val textureView = GLTextureView(context).apply {
|
|
19
|
+
// Order matters: EGL configuration must precede setRenderer.
|
|
20
|
+
setEGLContextClientVersion(2)
|
|
21
|
+
// RGBA8888 — the EGL surface needs a real alpha channel for transparency.
|
|
22
|
+
setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
|
23
|
+
isOpaque = false
|
|
24
|
+
setPreserveEGLContextOnPause(true)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private var player: ExoPlayer? = null
|
|
28
|
+
private var surface: Surface? = null
|
|
29
|
+
private var sourceUri: String? = null
|
|
30
|
+
|
|
31
|
+
var loop: Boolean = true
|
|
32
|
+
set(value) {
|
|
33
|
+
field = value
|
|
34
|
+
player?.repeatMode = if (value) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var paused: Boolean = false
|
|
38
|
+
set(value) {
|
|
39
|
+
field = value
|
|
40
|
+
player?.playWhenReady = !value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
init {
|
|
44
|
+
renderer.onRequestRender = { textureView.requestRender() }
|
|
45
|
+
// Fires on the GL thread on every EGL (re)creation — including view
|
|
46
|
+
// reattach after RN detached us. Re-bind the fresh Surface each time.
|
|
47
|
+
renderer.onSurfaceReady = { s ->
|
|
48
|
+
post {
|
|
49
|
+
surface?.release()
|
|
50
|
+
surface = s
|
|
51
|
+
attachSurfaceAndMaybePrepare()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
textureView.setRenderer(renderer)
|
|
55
|
+
textureView.renderMode = GLTextureView.RENDERMODE_WHEN_DIRTY
|
|
56
|
+
addView(textureView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun setSource(uri: String?) {
|
|
60
|
+
sourceUri = uri
|
|
61
|
+
attachSurfaceAndMaybePrepare()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Main thread only — ExoPlayer is single-threaded by contract.
|
|
65
|
+
private fun attachSurfaceAndMaybePrepare() {
|
|
66
|
+
val uri = sourceUri ?: return
|
|
67
|
+
val p = player ?: ExoPlayer.Builder(context).build().also { built ->
|
|
68
|
+
// Silent transparent clips must never take audio focus or duck music.
|
|
69
|
+
built.setAudioAttributes(AudioAttributes.DEFAULT, false)
|
|
70
|
+
built.volume = 0f
|
|
71
|
+
built.addListener(object : Player.Listener {
|
|
72
|
+
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
73
|
+
if (playbackState == Player.STATE_ENDED) {
|
|
74
|
+
onVideoEnd(mapOf())
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
player = built
|
|
79
|
+
}
|
|
80
|
+
surface?.let { p.setVideoSurface(it) }
|
|
81
|
+
p.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
|
82
|
+
if (p.currentMediaItem?.localConfiguration?.uri?.toString() != uri) {
|
|
83
|
+
p.setMediaItem(MediaItem.fromUri(uri))
|
|
84
|
+
p.prepare()
|
|
85
|
+
}
|
|
86
|
+
p.playWhenReady = !paused
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Called from OnViewDestroys (main thread) when RN destroys the view for
|
|
90
|
+
// good. NOT called on mere detach — the player intentionally survives
|
|
91
|
+
// detach/reattach cycles (lists, tab switches); only the EGL surface is
|
|
92
|
+
// recreated, and onSurfaceReady re-binds it.
|
|
93
|
+
fun release() {
|
|
94
|
+
player?.clearVideoSurface()
|
|
95
|
+
player?.release()
|
|
96
|
+
player = null
|
|
97
|
+
surface?.release()
|
|
98
|
+
surface = null
|
|
99
|
+
renderer.release()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pack-transparent-video — convert any video with an alpha channel (ProRes 4444,
|
|
4
|
+
* VP9/VP8 WebM, PNG/QTRLE/FFV1/Ut Video in .mov/.mkv/.avi) into an
|
|
5
|
+
* "alpha-packed" MP4 for <TransparentVideo>.
|
|
6
|
+
*
|
|
7
|
+
* The trick: the output MP4 is a completely normal H.264 video, twice as tall
|
|
8
|
+
* as the source. Top half = the color image (premultiplied), bottom half = the
|
|
9
|
+
* alpha channel repainted as a grayscale matte. At render time a Skia shader
|
|
10
|
+
* recombines the halves into a transparent image on the GPU. No codec alpha
|
|
11
|
+
* support needed → plays on every iOS/Android hardware decoder.
|
|
12
|
+
*
|
|
13
|
+
* Works on Windows, macOS and Linux; needs ffmpeg + ffprobe in PATH.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* npx pack-transparent-video <input.mov> [more.webm ...] [options]
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* -o, --out-dir <dir> output directory (default: cwd)
|
|
20
|
+
* --fps <n> frame rate of the packed output (default: 24)
|
|
21
|
+
* --quality <0-100> quality in percent, 75 ≈ visually lossless (default: 75)
|
|
22
|
+
* --scale <percent> resize to this % of the source size (default: 100)
|
|
23
|
+
* --width <px> or: resize to exact width, keeps aspect
|
|
24
|
+
* --size <WxH> or: exact output frame from any aspect ratio —
|
|
25
|
+
* scales to fit and pads with TRANSPARENT pixels
|
|
26
|
+
* --crf <n> advanced: raw x264 CRF, overrides --quality
|
|
27
|
+
*
|
|
28
|
+
* The output height must be divisible by 8 (packed height by 16) — Android
|
|
29
|
+
* hardware decoders align buffers to 16 rows, and non-aligned packed videos
|
|
30
|
+
* get a crop transform that shifts the alpha mask ~1px against the color.
|
|
31
|
+
* The CLI refuses misaligned resolutions and suggests the nearest valid one.
|
|
32
|
+
*
|
|
33
|
+
* Output: <out-dir>/<name>-packed.mp4 (a trailing "-4444" is stripped from
|
|
34
|
+
* the name). Prints the <TransparentVideo> line to paste into your app.
|
|
35
|
+
*
|
|
36
|
+
* Recommended source export (any editor/motion tool): QuickTime with
|
|
37
|
+
* "Apple ProRes 4444" and the alpha channel enabled, at display resolution
|
|
38
|
+
* (e.g. 900×900), 24–30 fps.
|
|
39
|
+
*/
|
|
40
|
+
import { execFileSync } from 'node:child_process';
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { parseArgs } from 'node:util';
|
|
44
|
+
|
|
45
|
+
function run(bin, args) {
|
|
46
|
+
return execFileSync(bin, args, { encoding: 'utf8' }).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(msg) {
|
|
50
|
+
console.error(`✖ ${msg}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- args -------------------------------------------------------------------
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = parseArgs({
|
|
58
|
+
allowPositionals: true,
|
|
59
|
+
options: {
|
|
60
|
+
'out-dir': { type: 'string', short: 'o', default: process.cwd() },
|
|
61
|
+
fps: { type: 'string', default: '24' },
|
|
62
|
+
quality: { type: 'string', default: '75' },
|
|
63
|
+
scale: { type: 'string', default: '' },
|
|
64
|
+
width: { type: 'string', default: '' },
|
|
65
|
+
size: { type: 'string', default: '' },
|
|
66
|
+
crf: { type: 'string', default: '' },
|
|
67
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
fail(e.message);
|
|
72
|
+
}
|
|
73
|
+
const { values, positionals: inputs } = parsed;
|
|
74
|
+
|
|
75
|
+
if (values.help || inputs.length === 0) {
|
|
76
|
+
console.log(
|
|
77
|
+
`Usage: pack-transparent-video <input-with-alpha> [...] [options]
|
|
78
|
+
-o, --out-dir <dir> output directory (default: cwd)
|
|
79
|
+
--fps <n> output frame rate (default: 24)
|
|
80
|
+
--quality <0-100> quality %, 75 ≈ visually lossless (default: 75)
|
|
81
|
+
--scale <percent> resize to % of source size (default: 100)
|
|
82
|
+
--width <px> or: exact output width, keeps aspect
|
|
83
|
+
--size <WxH> or: exact frame (e.g. 900x900) — fits + transparent padding
|
|
84
|
+
--crf <n> advanced: raw x264 CRF, overrides --quality`
|
|
85
|
+
);
|
|
86
|
+
process.exit(values.help ? 0 : 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const OUT_DIR = path.resolve(values['out-dir']);
|
|
90
|
+
const FPS = values.fps;
|
|
91
|
+
|
|
92
|
+
// Quality percentage → x264 CRF. 100% → 14 (near lossless), 75% → 18
|
|
93
|
+
// (visually lossless, the recommended default), 0% → 30 (small but rough).
|
|
94
|
+
const quality = Math.min(100, Math.max(0, Number(values.quality)));
|
|
95
|
+
if (Number.isNaN(quality)) fail(`--quality must be a number 0-100, got: ${values.quality}`);
|
|
96
|
+
const CRF = values.crf !== '' ? values.crf : String(Math.round(30 - quality * 0.16));
|
|
97
|
+
|
|
98
|
+
const scalePct = values.scale !== '' ? Number(values.scale) : 100;
|
|
99
|
+
if (Number.isNaN(scalePct) || scalePct <= 0 || scalePct > 400) {
|
|
100
|
+
fail(`--scale must be a percentage between 1 and 400, got: ${values.scale}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let sizeW = 0;
|
|
104
|
+
let sizeH = 0;
|
|
105
|
+
if (values.size !== '') {
|
|
106
|
+
const m = values.size.match(/^(\d+)x(\d+)$/i);
|
|
107
|
+
if (!m) fail(`--size must look like 900x900, got: ${values.size}`);
|
|
108
|
+
sizeW = 2 * Math.round(Number(m[1]) / 2);
|
|
109
|
+
sizeH = 2 * Math.round(Number(m[2]) / 2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- preflight ------------------------------------------------------------
|
|
113
|
+
try {
|
|
114
|
+
run('ffmpeg', ['-version']);
|
|
115
|
+
} catch {
|
|
116
|
+
fail(
|
|
117
|
+
'ffmpeg not found — install it first:\n' +
|
|
118
|
+
' macOS: brew install ffmpeg\n' +
|
|
119
|
+
' Windows: winget install ffmpeg\n' +
|
|
120
|
+
' Linux: sudo apt install ffmpeg'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
125
|
+
|
|
126
|
+
// ---- convert each input ---------------------------------------------------
|
|
127
|
+
for (const input of inputs) {
|
|
128
|
+
if (!fs.existsSync(input)) fail(`input not found: ${input}`);
|
|
129
|
+
|
|
130
|
+
// Verify the source actually carries alpha. Most codecs signal it in
|
|
131
|
+
// pix_fmt; VP8/VP9 WebM signals it via the alpha_mode container tag (their
|
|
132
|
+
// pix_fmt reads yuv420p even when alpha is present).
|
|
133
|
+
const probe = JSON.parse(
|
|
134
|
+
run('ffprobe', [
|
|
135
|
+
'-v', 'error', '-select_streams', 'v:0',
|
|
136
|
+
'-show_entries', 'stream=codec_name,pix_fmt,width,height:stream_tags=alpha_mode',
|
|
137
|
+
'-of', 'json', input,
|
|
138
|
+
])
|
|
139
|
+
);
|
|
140
|
+
const stream = probe.streams?.[0] ?? {};
|
|
141
|
+
const pixFmt = stream.pix_fmt ?? '';
|
|
142
|
+
const codec = stream.codec_name ?? '';
|
|
143
|
+
const srcW = Number(stream.width);
|
|
144
|
+
const srcH = Number(stream.height);
|
|
145
|
+
if (!srcW || !srcH) fail(`${path.basename(input)}: could not read video dimensions`);
|
|
146
|
+
const vpxAlpha = ['vp8', 'vp9'].includes(codec) && stream.tags?.alpha_mode === '1';
|
|
147
|
+
if (!pixFmt.includes('a') && !vpxAlpha) {
|
|
148
|
+
fail(
|
|
149
|
+
`${path.basename(input)} has no alpha channel (codec: ${codec}, pix_fmt: ${pixFmt}).\n` +
|
|
150
|
+
' Re-export as ProRes 4444 with the alpha channel enabled (every editor/motion tool supports this).\n' +
|
|
151
|
+
' (Plain H.264/HEVC exports have no alpha; HEVC-with-alpha is not supported — use ProRes 4444 or VP9 WebM.)'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Strip a "4444" token anywhere in the name: raccoon-4444-idle → raccoon-idle
|
|
156
|
+
const name = path
|
|
157
|
+
.basename(input, path.extname(input))
|
|
158
|
+
.replace(/(^|-)4444(?=-|$)/g, '$1')
|
|
159
|
+
.replace(/--+/g, '-')
|
|
160
|
+
.replace(/^-|-$/g, '');
|
|
161
|
+
const outFile = path.join(OUT_DIR, `${name}-packed.mp4`);
|
|
162
|
+
|
|
163
|
+
// Resize stage (runs in rgba so padding can be transparent):
|
|
164
|
+
// --size: scale to fit the exact frame, pad the rest with TRANSPARENT
|
|
165
|
+
// pixels (alpha 0) — never stretches, works for any aspect ratio
|
|
166
|
+
// --width: exact width, height follows aspect
|
|
167
|
+
// --scale: percentage of source
|
|
168
|
+
// default: no-op even-ing pass (yuv420p + vstack need even dimensions)
|
|
169
|
+
const even = (n) => Math.round(n / 2) * 2;
|
|
170
|
+
let outW;
|
|
171
|
+
let outH;
|
|
172
|
+
if (sizeW) {
|
|
173
|
+
outW = sizeW;
|
|
174
|
+
outH = sizeH;
|
|
175
|
+
} else if (values.width) {
|
|
176
|
+
outW = even(Number(values.width));
|
|
177
|
+
outH = even((srcH * outW) / srcW);
|
|
178
|
+
} else {
|
|
179
|
+
outW = even((srcW * scalePct) / 100 - 0.49);
|
|
180
|
+
outH = even((srcH * outW) / srcW);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The output height MUST be divisible by 8 (packed = 2x → divisible by 16):
|
|
184
|
+
// Android hardware decoders align video buffers to 16 rows, and a
|
|
185
|
+
// non-aligned packed video forces a crop transform whose filtering inset
|
|
186
|
+
// shifts the alpha mask ~1px against the color on every Android device.
|
|
187
|
+
if (outH % 8 !== 0) {
|
|
188
|
+
let fix;
|
|
189
|
+
if (sizeW) {
|
|
190
|
+
const lo = Math.floor(outH / 8) * 8;
|
|
191
|
+
const hi = lo + 8;
|
|
192
|
+
fix = `use --size ${outW}x${lo}${lo > 0 ? '' : ''} or --size ${outW}x${hi}`;
|
|
193
|
+
} else {
|
|
194
|
+
const candidates = [];
|
|
195
|
+
for (let d = 2; d <= 128 && candidates.length < 2; d += 2) {
|
|
196
|
+
for (const w of [outW - d, outW + d]) {
|
|
197
|
+
if (w > 0 && even((srcH * w) / srcW) % 8 === 0 && !candidates.includes(w)) {
|
|
198
|
+
candidates.push(w);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
candidates.sort((a, b) => Math.abs(a - outW) - Math.abs(b - outW));
|
|
203
|
+
fix = candidates.length
|
|
204
|
+
? `use ${candidates.map((w) => `--width ${w}`).join(' or ')}`
|
|
205
|
+
: 'pick a resolution whose height is divisible by 8 (e.g. via --size)';
|
|
206
|
+
}
|
|
207
|
+
fail(
|
|
208
|
+
`${path.basename(input)}: output ${outW}x${outH} — height ${outH} is not divisible by 8.\n` +
|
|
209
|
+
' Why: Android hardware decoders align video buffers to 16 rows; a packed video\n' +
|
|
210
|
+
' whose height is not 16-aligned gets a crop transform that shifts the alpha mask\n' +
|
|
211
|
+
' ~1px against the color on every Android device.\n' +
|
|
212
|
+
` Fix: ${fix} (source is ${srcW}x${srcH})`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const resize = sizeW
|
|
217
|
+
? `scale=${sizeW}:${sizeH}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
|
|
218
|
+
`pad=${sizeW}:${sizeH}:(ow-iw)/2:(oh-ih)/2:color=black@0,`
|
|
219
|
+
: `scale=${outW}:${outH},`;
|
|
220
|
+
|
|
221
|
+
// fps → resize → premultiply color by alpha (kills edge fringe when the
|
|
222
|
+
// shader samples with bilinear filtering) → split → alphaextract paints the
|
|
223
|
+
// alpha as grayscale → vstack glues color above matte into one tall frame.
|
|
224
|
+
const filter =
|
|
225
|
+
`[0:v]fps=${FPS},format=rgba,${resize}premultiply=inplace=1,format=rgba,` +
|
|
226
|
+
`split[c][a];[a]alphaextract[m];[c][m]vstack`;
|
|
227
|
+
|
|
228
|
+
console.log(
|
|
229
|
+
`→ packing ${path.basename(input)} (${codec}/${pixFmt || 'alpha_mode=1'}, ${FPS} fps, quality ${quality}% = crf ${CRF})...`
|
|
230
|
+
);
|
|
231
|
+
execFileSync('ffmpeg', [
|
|
232
|
+
'-y', '-hide_banner', '-loglevel', 'error',
|
|
233
|
+
// The native vp8/vp9 decoders ignore alpha; the libvpx ones decode it.
|
|
234
|
+
...(vpxAlpha ? ['-c:v', codec === 'vp8' ? 'libvpx' : 'libvpx-vp9'] : []),
|
|
235
|
+
'-i', input,
|
|
236
|
+
'-filter_complex', filter,
|
|
237
|
+
'-c:v', 'libx264', '-crf', CRF, '-pix_fmt', 'yuv420p',
|
|
238
|
+
'-movflags', '+faststart', '-an',
|
|
239
|
+
outFile,
|
|
240
|
+
], { stdio: 'inherit' });
|
|
241
|
+
|
|
242
|
+
const mb = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2);
|
|
243
|
+
console.log(`✔ ${outFile} (${mb} MB)`);
|
|
244
|
+
console.log(
|
|
245
|
+
` use it: <TransparentVideo source={require('./${path.basename(outFile)}')} width={...} height={...} />\n`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
4
|
+
export interface TransparentVideoProps {
|
|
5
|
+
/**
|
|
6
|
+
* An alpha-packed video (color on the top half, grayscale matte on the
|
|
7
|
+
* bottom half): either a bundled asset module (`require('./x-packed.mp4')`)
|
|
8
|
+
* or a URI string (file:// or https://).
|
|
9
|
+
*/
|
|
10
|
+
source: number | string;
|
|
11
|
+
/** Display width. The packed video's own height is 2x its visible height. */
|
|
12
|
+
width: number;
|
|
13
|
+
/** Display height (half the packed video's pixel height). */
|
|
14
|
+
height: number;
|
|
15
|
+
/** Loop playback (default true). Set false for one-shot animations. */
|
|
16
|
+
loop?: boolean;
|
|
17
|
+
paused?: SharedValue<boolean> | boolean;
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
/**
|
|
20
|
+
* Fires when a non-looping video finishes playing.
|
|
21
|
+
* Currently Android-only (native player event); on iOS it never fires —
|
|
22
|
+
* time-based fallbacks remain the caller's responsibility there.
|
|
23
|
+
*/
|
|
24
|
+
onEnd?: () => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Plays an alpha-packed MP4 with real transparency.
|
|
28
|
+
*
|
|
29
|
+
* Platform split: iOS/web render through Skia (RuntimeEffect unpack shader);
|
|
30
|
+
* Android uses a native ExoPlayer + OpenGL view (Metro resolves
|
|
31
|
+
* ./TransparentVideoView to the .android implementation there).
|
|
32
|
+
*/
|
|
33
|
+
export declare function TransparentVideo({ source, width, height, loop, paused, style, onEnd, }: TransparentVideoProps): React.JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TransparentVideo = TransparentVideo;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const TransparentVideoView_1 = require("./TransparentVideoView");
|
|
6
|
+
const useResolvedUri_1 = require("./useResolvedUri");
|
|
7
|
+
/**
|
|
8
|
+
* Plays an alpha-packed MP4 with real transparency.
|
|
9
|
+
*
|
|
10
|
+
* Platform split: iOS/web render through Skia (RuntimeEffect unpack shader);
|
|
11
|
+
* Android uses a native ExoPlayer + OpenGL view (Metro resolves
|
|
12
|
+
* ./TransparentVideoView to the .android implementation there).
|
|
13
|
+
*/
|
|
14
|
+
function TransparentVideo({ source, width, height, loop = true, paused = false, style, onEnd, }) {
|
|
15
|
+
const uri = (0, useResolvedUri_1.useResolvedUri)(source);
|
|
16
|
+
return ((0, jsx_runtime_1.jsx)(TransparentVideoView_1.TransparentVideoView, { uri: uri, width: width, height: height, loop: loop, paused: paused, style: style, onEnd: onEnd }));
|
|
17
|
+
}
|