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.
@@ -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,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["expo.modules.transparentvideo.TransparentVideoModule"]
5
+ }
6
+ }
@@ -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
+ }