@pickleball/rtmp-native 0.1.0 → 0.2.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.
@@ -0,0 +1,755 @@
1
+ package expo.modules.pickleballrtmp
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.Canvas
7
+ import android.graphics.Movie
8
+ import android.graphics.Paint
9
+ import android.graphics.PorterDuff
10
+ import android.graphics.RectF
11
+ import android.graphics.SurfaceTexture
12
+ import android.graphics.Typeface
13
+ import android.media.MediaPlayer
14
+ import android.opengl.EGL14
15
+ import android.opengl.EGLConfig
16
+ import android.opengl.EGLContext
17
+ import android.opengl.EGLDisplay
18
+ import android.opengl.EGLExt
19
+ import android.opengl.EGLSurface
20
+ import android.opengl.GLES11Ext
21
+ import android.opengl.GLES20
22
+ import android.opengl.GLUtils
23
+ import android.os.Handler
24
+ import android.os.HandlerThread
25
+ import android.os.SystemClock
26
+ import android.view.Surface
27
+ import java.io.File
28
+ import java.nio.ByteBuffer
29
+ import java.nio.ByteOrder
30
+ import java.nio.FloatBuffer
31
+ import java.util.concurrent.CountDownLatch
32
+
33
+ /**
34
+ * Pass GL chen giữa Camera2 và encoder — CHỈ tồn tại khi giải có overlay.
35
+ *
36
+ * Camera2 ──addTarget──▶ Surface(SurfaceTexture OES)
37
+ * │ onFrameAvailable (nhịp = camera, ~30fps)
38
+ * ▼
39
+ * GlCompositor (EGL riêng, 1 render thread)
40
+ * camera quad + từng lớp overlay theo thứ tự
41
+ * ├─▶ EGLSurface(encoder input) (+presentation time)
42
+ * └─▶ EGLSurface(preview view) (nếu có)
43
+ *
44
+ * Không có overlay thì module đi đường addTarget thẳng như kiến trúc 08/08 —
45
+ * pass này là opt-in, không phải hồi sinh tầng GL cũ. Hai bài học từ tầng cũ
46
+ * được khắc ở đây:
47
+ * · GL_LINEAR cho MỌI texture (GL_NEAREST là lý do tầng cũ bị gỡ — răng cưa);
48
+ * · blend GL_ONE / GL_ONE_MINUS_SRC_ALPHA vì Bitmap Android decode
49
+ * PREMULTIPLIED — dùng GL_SRC_ALPHA là viền đen quanh mép trong suốt.
50
+ *
51
+ * Timestamp: lấy từ SurfaceTexture của camera và đóng qua
52
+ * eglPresentationTimeANDROID — encoder KHÔNG được tự đóng dấu (VFR loạn nhịp).
53
+ *
54
+ * GIF đi đường Movie (deprecated nhưng vẫn sống ở API 35): mình tự cầm đồng hồ
55
+ * (setTime theo elapsed % duration) nên nhịp khung tất định như GifSource bên
56
+ * iOS, RAM O(1 khung). Movie chết ở Android nào đó thì lớp đó rơi riêng —
57
+ * animation là trang trí, không được đụng đường phát.
58
+ * VIDEO đi MediaPlayer → SurfaceTexture OES thứ hai, loop + câm tiếng.
59
+ */
60
+ internal class GlCompositor(
61
+ private val context: Context,
62
+ private val outWidth: Int,
63
+ private val outHeight: Int,
64
+ private val encoderSurface: Surface,
65
+ /**
66
+ * Xoay nguồn camera (độ, bội 90). Chuẩn tham chiếu là CAMERA SAU của Redmi
67
+ * (sensor 90° — buffer tự nhiên đúng chiều ngang, đã kiểm 2 ngày field):
68
+ * module truyền (SENSOR_ORIENTATION − 90). Camera TRƯỚC (sensor 270) → 180.
69
+ * Hiện chỉ xử 0/180; 90/270 (dựng dọc) ngoài phạm vi như đường cũ.
70
+ */
71
+ private val cameraRotation: Int = 0,
72
+ ) {
73
+ /** Một lớp overlay theo hợp đồng JS (pixel của khung encode, gốc trên-trái). */
74
+ data class LayerSpec(
75
+ val id: String,
76
+ val kind: String, // "image" | "gif" | "video"
77
+ val filePath: String,
78
+ val x: Int,
79
+ val y: Int,
80
+ val width: Int,
81
+ val height: Int,
82
+ val opacity: Float,
83
+ )
84
+
85
+ /**
86
+ * Một lớp CHỮ đã điền sẵn (JS điền template — native chỉ vẽ). `x` là MỎ NEO
87
+ * theo `align`; màu đã parse thành ARGB ở module (bgArgb null = không nền,
88
+ * alpha của nền nằm trong chính bgArgb).
89
+ */
90
+ data class TextSpec(
91
+ val id: String,
92
+ val text: String,
93
+ val x: Int,
94
+ val y: Int,
95
+ val fontSizePx: Int,
96
+ val align: String, // "left" | "center" | "right"
97
+ val bold: Boolean,
98
+ val colorArgb: Int,
99
+ val opacity: Float,
100
+ val bgArgb: Int?,
101
+ )
102
+
103
+ private val thread = HandlerThread("pickleball-gl").also { it.start() }
104
+ private val handler = Handler(thread.looper)
105
+
106
+ private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
107
+ private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
108
+ private var eglConfig: EGLConfig? = null
109
+ private var encoderEgl: EGLSurface = EGL14.EGL_NO_SURFACE
110
+ private var previewEgl: EGLSurface = EGL14.EGL_NO_SURFACE
111
+ private var previewW = 0
112
+ private var previewH = 0
113
+
114
+ private var cameraTexId = 0
115
+ private var cameraST: SurfaceTexture? = null
116
+ /** Camera2 addTarget vào đây thay vì encoder surface. */
117
+ @Volatile var inputSurface: Surface? = null
118
+ private set
119
+
120
+ private var oesProgram = 0
121
+ private var rgbaProgram = 0
122
+ private val camMatrix = FloatArray(16)
123
+
124
+ private var layers: List<RuntimeLayer> = emptyList()
125
+ /** Lớp CHỮ — danh sách RIÊNG, vẽ TRÊN mọi lớp media. Tách để cập nhật chữ
126
+ * (2 lần/phút theo pha bóng) KHÔNG đụng tới media layer: setLayers rebuild
127
+ * là restart MediaPlayer của lớp video — churn đã đo, cấm tái phạm. */
128
+ private var textLayers: List<TextLayer> = emptyList()
129
+ @Volatile private var released = false
130
+
131
+ /** Báo cáo dựng lớp lần gần nhất — module trả về JS để log. */
132
+ @Volatile var lastLayerReport: List<Map<String, Any>> = emptyList()
133
+ private set
134
+
135
+ /** Báo cáo lớp chữ lần áp gần nhất. */
136
+ @Volatile var lastTextReport: List<Map<String, Any>> = emptyList()
137
+ private set
138
+
139
+ init {
140
+ runBlockingOnGl {
141
+ setupEgl()
142
+ setupCameraSource()
143
+ }
144
+ }
145
+
146
+ // MARK: - API (mọi việc GL đều nhảy vào render thread)
147
+
148
+ fun setLayers(specs: List<LayerSpec>) {
149
+ runBlockingOnGl {
150
+ layers.forEach { it.release() }
151
+ val report = mutableListOf<Map<String, Any>>()
152
+ layers = specs.mapNotNull { spec ->
153
+ val built = buildLayer(spec)
154
+ report.add(
155
+ if (built != null) {
156
+ mapOf("id" to spec.id, "applied" to true, "kind" to spec.kind)
157
+ } else {
158
+ mapOf("id" to spec.id, "applied" to false, "why" to "${spec.kind}_decode_failed")
159
+ },
160
+ )
161
+ built
162
+ }
163
+ lastLayerReport = report
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Áp danh sách lớp CHỮ — cập nhật TẠI CHỖ theo id: chữ đổi thì render lại
169
+ * bitmap + texImage2D lên texture cũ; media layers không bị chạm. Spec y hệt
170
+ * lần trước thì lớp đó là no-op tuyệt đối.
171
+ */
172
+ fun setTextLayers(specs: List<TextSpec>) {
173
+ runBlockingOnGl {
174
+ val byId = textLayers.associateBy { it.id }.toMutableMap()
175
+ val report = mutableListOf<Map<String, Any>>()
176
+ val next = mutableListOf<TextLayer>()
177
+ for (spec in specs) {
178
+ // Tái dùng texture theo id — lấy RA khỏi map để phần còn lại là "lớp bị gỡ".
179
+ val layer = byId.remove(spec.id) ?: TextLayer(spec.id, makeTexture2D())
180
+ val ok = runCatching { layer.update(this, spec) }.getOrDefault(false)
181
+ if (ok) {
182
+ next.add(layer)
183
+ report.add(mapOf("id" to spec.id, "applied" to true))
184
+ } else {
185
+ // Lớp hỏng rơi RIÊNG (chữ là trang trí) — trả texture, không giữ xác.
186
+ layer.release()
187
+ report.add(mapOf("id" to spec.id, "applied" to false, "why" to "text_render_failed"))
188
+ }
189
+ }
190
+ byId.values.forEach { it.release() }
191
+ textLayers = next
192
+ lastTextReport = report
193
+ }
194
+ }
195
+
196
+ /** Preview view đến/đi không cần dựng lại camera — chỉ thay EGLSurface đích. */
197
+ fun setPreviewSurface(surface: Surface?, width: Int, height: Int) {
198
+ runBlockingOnGl {
199
+ if (previewEgl != EGL14.EGL_NO_SURFACE) {
200
+ EGL14.eglDestroySurface(eglDisplay, previewEgl)
201
+ previewEgl = EGL14.EGL_NO_SURFACE
202
+ }
203
+ if (surface != null && surface.isValid) {
204
+ previewEgl = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, intArrayOf(EGL14.EGL_NONE), 0)
205
+ previewW = width
206
+ previewH = height
207
+ }
208
+ }
209
+ }
210
+
211
+ fun release() {
212
+ released = true
213
+ runBlockingOnGl {
214
+ layers.forEach { it.release() }
215
+ layers = emptyList()
216
+ textLayers.forEach { it.release() }
217
+ textLayers = emptyList()
218
+ cameraST?.setOnFrameAvailableListener(null)
219
+ inputSurface?.release()
220
+ inputSurface = null
221
+ cameraST?.release()
222
+ cameraST = null
223
+ if (oesProgram != 0) GLES20.glDeleteProgram(oesProgram)
224
+ if (rgbaProgram != 0) GLES20.glDeleteProgram(rgbaProgram)
225
+ if (cameraTexId != 0) GLES20.glDeleteTextures(1, intArrayOf(cameraTexId), 0)
226
+ if (previewEgl != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(eglDisplay, previewEgl)
227
+ if (encoderEgl != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(eglDisplay, encoderEgl)
228
+ EGL14.eglMakeCurrent(
229
+ eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT,
230
+ )
231
+ if (eglContext != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(eglDisplay, eglContext)
232
+ if (eglDisplay != EGL14.EGL_NO_DISPLAY) EGL14.eglTerminate(eglDisplay)
233
+ }
234
+ thread.quitSafely()
235
+ }
236
+
237
+ // MARK: - EGL + camera source
238
+
239
+ private fun setupEgl() {
240
+ eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
241
+ check(eglDisplay != EGL14.EGL_NO_DISPLAY) { "không lấy được EGL display" }
242
+ val version = IntArray(2)
243
+ check(EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { "eglInitialize thất bại" }
244
+ val attribs = intArrayOf(
245
+ EGL14.EGL_RED_SIZE, 8,
246
+ EGL14.EGL_GREEN_SIZE, 8,
247
+ EGL14.EGL_BLUE_SIZE, 8,
248
+ EGL14.EGL_ALPHA_SIZE, 8,
249
+ EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
250
+ // EGL_RECORDABLE_ANDROID: bắt buộc để ghi được vào surface của MediaCodec.
251
+ 0x3142, 1,
252
+ EGL14.EGL_NONE,
253
+ )
254
+ val configs = arrayOfNulls<EGLConfig>(1)
255
+ val num = IntArray(1)
256
+ check(EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, num, 0) && num[0] > 0) {
257
+ "không có EGL config phù hợp"
258
+ }
259
+ eglConfig = configs[0]
260
+ eglContext = EGL14.eglCreateContext(
261
+ eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT,
262
+ intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
263
+ )
264
+ check(eglContext != EGL14.EGL_NO_CONTEXT) { "không tạo được EGL context" }
265
+ encoderEgl = EGL14.eglCreateWindowSurface(
266
+ eglDisplay, eglConfig, encoderSurface, intArrayOf(EGL14.EGL_NONE), 0,
267
+ )
268
+ check(encoderEgl != EGL14.EGL_NO_SURFACE) { "không tạo được EGLSurface cho encoder" }
269
+ check(EGL14.eglMakeCurrent(eglDisplay, encoderEgl, encoderEgl, eglContext)) {
270
+ "eglMakeCurrent thất bại"
271
+ }
272
+ oesProgram = buildProgram(VERTEX_SHADER, FS_OES)
273
+ rgbaProgram = buildProgram(VERTEX_SHADER, FS_RGBA)
274
+ }
275
+
276
+ private fun setupCameraSource() {
277
+ val ids = IntArray(1)
278
+ GLES20.glGenTextures(1, ids, 0)
279
+ cameraTexId = ids[0]
280
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTexId)
281
+ linearClamp(GLES11Ext.GL_TEXTURE_EXTERNAL_OES)
282
+ val st = SurfaceTexture(cameraTexId)
283
+ st.setDefaultBufferSize(outWidth, outHeight)
284
+ // Nghe trên CHÍNH render thread — khung camera đến là vẽ, không nhảy thread.
285
+ st.setOnFrameAvailableListener({ onCameraFrame() }, handler)
286
+ cameraST = st
287
+ inputSurface = Surface(st)
288
+ }
289
+
290
+ // MARK: - Render
291
+
292
+ private fun onCameraFrame() {
293
+ if (released) return
294
+ val st = cameraST ?: return
295
+ if (!EGL14.eglMakeCurrent(eglDisplay, encoderEgl, encoderEgl, eglContext)) return
296
+ st.updateTexImage()
297
+ st.getTransformMatrix(camMatrix)
298
+ layers.forEach { it.tick() }
299
+
300
+ drawScene(outWidth, outHeight)
301
+ EGLExt.eglPresentationTimeANDROID(eglDisplay, encoderEgl, st.timestamp)
302
+ EGL14.eglSwapBuffers(eglDisplay, encoderEgl)
303
+
304
+ if (previewEgl != EGL14.EGL_NO_SURFACE) {
305
+ if (EGL14.eglMakeCurrent(eglDisplay, previewEgl, previewEgl, eglContext)) {
306
+ drawScene(previewW, previewH)
307
+ EGL14.eglSwapBuffers(eglDisplay, previewEgl)
308
+ }
309
+ }
310
+ }
311
+
312
+ private fun drawScene(viewW: Int, viewH: Int) {
313
+ GLES20.glViewport(0, 0, viewW, viewH)
314
+ GLES20.glClearColor(0f, 0f, 0f, 1f)
315
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
316
+ // Camera full khung — KHÔNG dùng transform matrix của SurfaceTexture: ma
317
+ // trận đó xoay ảnh về chiều dọc-tự-nhiên của điện thoại (chuẩn hiển thị),
318
+ // vẽ vào khung ngang là xoay 90° + bóp dẹt (đo thật 26/08 tối). Đường
319
+ // encoder trực tiếp (không GL, đã kiểm field) nhận BUFFER THÔ — GL phải
320
+ // sample đúng buffer thô đó: identity + lật v cho hệ GL.
321
+ drawQuad(
322
+ oesProgram, GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTexId,
323
+ if (cameraRotation == 180) FULL_RECT_NDC_ROT180 else FULL_RECT_NDC,
324
+ IDENTITY, 1f, blend = false,
325
+ )
326
+ for (layer in layers) {
327
+ layer.draw(this)
328
+ }
329
+ // Chữ trên cùng — sau mọi lớp media.
330
+ for (layer in textLayers) {
331
+ layer.draw(this)
332
+ }
333
+ }
334
+
335
+ internal fun drawLayerQuad(
336
+ program: Int, target: Int, texId: Int,
337
+ rect: FloatArray, texMatrix: FloatArray, opacity: Float,
338
+ ) = drawQuad(program, target, texId, rect, texMatrix, opacity, blend = true)
339
+
340
+ private fun drawQuad(
341
+ program: Int, target: Int, texId: Int,
342
+ ndcRect: FloatArray, texMatrix: FloatArray, opacity: Float, blend: Boolean,
343
+ ) {
344
+ GLES20.glUseProgram(program)
345
+ if (blend) {
346
+ GLES20.glEnable(GLES20.GL_BLEND)
347
+ // Premultiplied alpha — GL_ONE, không phải GL_SRC_ALPHA (viền đen).
348
+ GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA)
349
+ } else {
350
+ GLES20.glDisable(GLES20.GL_BLEND)
351
+ }
352
+ val pos = GLES20.glGetAttribLocation(program, "aPos")
353
+ val tex = GLES20.glGetAttribLocation(program, "aTex")
354
+ val uM = GLES20.glGetUniformLocation(program, "uTexMatrix")
355
+ val uA = GLES20.glGetUniformLocation(program, "uAlpha")
356
+ val uS = GLES20.glGetUniformLocation(program, "uTexture")
357
+
358
+ vertexBuf.clear()
359
+ vertexBuf.put(ndcRect).position(0)
360
+ GLES20.glEnableVertexAttribArray(pos)
361
+ GLES20.glVertexAttribPointer(pos, 2, GLES20.GL_FLOAT, false, 16, vertexBuf)
362
+ vertexBuf.position(2)
363
+ GLES20.glEnableVertexAttribArray(tex)
364
+ GLES20.glVertexAttribPointer(tex, 2, GLES20.GL_FLOAT, false, 16, vertexBuf)
365
+
366
+ GLES20.glUniformMatrix4fv(uM, 1, false, texMatrix, 0)
367
+ GLES20.glUniform1f(uA, opacity)
368
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
369
+ GLES20.glBindTexture(target, texId)
370
+ GLES20.glUniform1i(uS, 0)
371
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
372
+ GLES20.glDisableVertexAttribArray(pos)
373
+ GLES20.glDisableVertexAttribArray(tex)
374
+ }
375
+
376
+ /**
377
+ * [x,y,w,h] pixel (gốc trên-trái) → strip NDC xen kẽ pos/tex (x,y,u,v ×4).
378
+ * Hai hệ v NGƯỢC nhau: bitmap upload hàng đầu = mép TRÊN (v=0 ở đỉnh), còn
379
+ * SurfaceTexture OES (video/camera) theo hệ GL v=0 ở ĐÁY — đo thật 26/08:
380
+ * dùng hệ bitmap cho video là chữ trên sóng lật ngược.
381
+ */
382
+ internal fun ndcRectOf(x: Int, y: Int, w: Int, h: Int, oesV: Boolean = false): FloatArray {
383
+ val x0 = 2f * x / outWidth - 1f
384
+ val x1 = 2f * (x + w) / outWidth - 1f
385
+ val yTop = 1f - 2f * y / outHeight
386
+ val yBot = 1f - 2f * (y + h) / outHeight
387
+ val vTop = if (oesV) 1f else 0f
388
+ val vBot = if (oesV) 0f else 1f
389
+ return floatArrayOf(
390
+ x0, yBot, 0f, vBot,
391
+ x1, yBot, 1f, vBot,
392
+ x0, yTop, 0f, vTop,
393
+ x1, yTop, 1f, vTop,
394
+ )
395
+ }
396
+
397
+ // MARK: - Layers
398
+
399
+ private fun buildLayer(spec: LayerSpec): RuntimeLayer? {
400
+ if (spec.width < 1 || spec.height < 1) return null
401
+ if (!File(spec.filePath).exists()) return null
402
+ return when (spec.kind) {
403
+ "image" -> StaticLayer.create(this, spec)
404
+ "gif" -> GifLayer.create(this, spec)
405
+ "video" -> VideoLayer.create(this, spec)
406
+ else -> null
407
+ }
408
+ }
409
+
410
+ internal fun makeTexture2D(): Int {
411
+ val ids = IntArray(1)
412
+ GLES20.glGenTextures(1, ids, 0)
413
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0])
414
+ linearClamp(GLES20.GL_TEXTURE_2D)
415
+ return ids[0]
416
+ }
417
+
418
+ internal fun makeTextureOes(): Int {
419
+ val ids = IntArray(1)
420
+ GLES20.glGenTextures(1, ids, 0)
421
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
422
+ linearClamp(GLES11Ext.GL_TEXTURE_EXTERNAL_OES)
423
+ return ids[0]
424
+ }
425
+
426
+ private fun linearClamp(target: Int) {
427
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
428
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
429
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
430
+ GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
431
+ }
432
+
433
+ internal abstract class RuntimeLayer {
434
+ /** Cập nhật nguồn (khung gif/video mới) — chạy trên render thread. */
435
+ open fun tick() = Unit
436
+ abstract fun draw(gl: GlCompositor)
437
+ open fun release() = Unit
438
+ }
439
+
440
+ /** Ảnh tĩnh (composite/logo): upload MỘT lần, mỗi khung chỉ còn draw. */
441
+ private class StaticLayer(
442
+ private val texId: Int,
443
+ private val rect: FloatArray,
444
+ private val opacity: Float,
445
+ ) : RuntimeLayer() {
446
+ override fun draw(gl: GlCompositor) =
447
+ gl.drawLayerQuad(gl.rgbaProgram, GLES20.GL_TEXTURE_2D, texId, rect, IDENTITY, opacity)
448
+
449
+ override fun release() {
450
+ GLES20.glDeleteTextures(1, intArrayOf(texId), 0)
451
+ }
452
+
453
+ companion object {
454
+ fun create(gl: GlCompositor, spec: LayerSpec): StaticLayer? {
455
+ val bitmap = BitmapFactory.decodeFile(spec.filePath) ?: return null
456
+ val texId = gl.makeTexture2D()
457
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
458
+ bitmap.recycle()
459
+ return StaticLayer(texId, gl.ndcRectOf(spec.x, spec.y, spec.width, spec.height), spec.opacity)
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * GIF qua Movie: mình cầm đồng hồ (elapsed % duration), mỗi tick vẽ đúng một
466
+ * khung vào bitmap cỡ ô rồi texSubImage2D — RAM O(1 khung) như GifSource iOS.
467
+ */
468
+ private class GifLayer(
469
+ private val movie: Movie,
470
+ private val texId: Int,
471
+ private val bitmap: Bitmap,
472
+ private val canvas: Canvas,
473
+ private val rect: FloatArray,
474
+ private val opacity: Float,
475
+ ) : RuntimeLayer() {
476
+ private val startedAt = SystemClock.elapsedRealtime()
477
+ private val durationMs = movie.duration().coerceAtLeast(1)
478
+ private var lastShownMs = -1
479
+
480
+ override fun tick() {
481
+ val t = ((SystemClock.elapsedRealtime() - startedAt) % durationMs).toInt()
482
+ // Movie tự biết khung nào ứng thời điểm nào; chỉ upload khi mốc đổi.
483
+ if (t == lastShownMs) return
484
+ lastShownMs = t
485
+ movie.setTime(t)
486
+ canvas.drawColor(0, PorterDuff.Mode.CLEAR)
487
+ canvas.save()
488
+ canvas.scale(
489
+ bitmap.width.toFloat() / movie.width().coerceAtLeast(1),
490
+ bitmap.height.toFloat() / movie.height().coerceAtLeast(1),
491
+ )
492
+ movie.draw(canvas, 0f, 0f)
493
+ canvas.restore()
494
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId)
495
+ GLUtils.texSubImage2D(GLES20.GL_TEXTURE_2D, 0, 0, 0, bitmap)
496
+ }
497
+
498
+ override fun draw(gl: GlCompositor) =
499
+ gl.drawLayerQuad(gl.rgbaProgram, GLES20.GL_TEXTURE_2D, texId, rect, IDENTITY, opacity)
500
+
501
+ override fun release() {
502
+ GLES20.glDeleteTextures(1, intArrayOf(texId), 0)
503
+ bitmap.recycle()
504
+ }
505
+
506
+ companion object {
507
+ @Suppress("DEPRECATION")
508
+ fun create(gl: GlCompositor, spec: LayerSpec): GifLayer? {
509
+ val movie = runCatching { Movie.decodeFile(spec.filePath) }.getOrNull() ?: return null
510
+ if (movie.width() < 1 || movie.height() < 1) return null
511
+ val bitmap = Bitmap.createBitmap(spec.width, spec.height, Bitmap.Config.ARGB_8888)
512
+ val canvas = Canvas(bitmap)
513
+ val texId = gl.makeTexture2D()
514
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
515
+ return GifLayer(
516
+ movie, texId, bitmap, canvas,
517
+ gl.ndcRectOf(spec.x, spec.y, spec.width, spec.height), spec.opacity,
518
+ )
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * VIDEO qua MediaPlayer → SurfaceTexture OES riêng: decode cứng, loop, câm
525
+ * tiếng. Opacity ép 1 (khớp iOS — nhãn UI đã nói thật điều này).
526
+ */
527
+ private class VideoLayer(
528
+ private val player: MediaPlayer,
529
+ private val texId: Int,
530
+ private val st: SurfaceTexture,
531
+ private val surface: Surface,
532
+ private val rect: FloatArray,
533
+ ) : RuntimeLayer() {
534
+ @Volatile private var frameReady = false
535
+ private val matrix = FloatArray(16)
536
+
537
+ init {
538
+ st.setOnFrameAvailableListener { frameReady = true }
539
+ android.opengl.Matrix.setIdentityM(matrix, 0)
540
+ }
541
+
542
+ override fun tick() {
543
+ if (!frameReady) return
544
+ frameReady = false
545
+ st.updateTexImage()
546
+ st.getTransformMatrix(matrix)
547
+ }
548
+
549
+ override fun draw(gl: GlCompositor) =
550
+ gl.drawLayerQuad(gl.oesProgram, GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId, rect, matrix, 1f)
551
+
552
+ override fun release() {
553
+ runCatching { player.stop() }
554
+ runCatching { player.release() }
555
+ st.setOnFrameAvailableListener(null)
556
+ surface.release()
557
+ st.release()
558
+ GLES20.glDeleteTextures(1, intArrayOf(texId), 0)
559
+ }
560
+
561
+ companion object {
562
+ fun create(gl: GlCompositor, spec: LayerSpec): VideoLayer? {
563
+ val texId = gl.makeTextureOes()
564
+ val st = SurfaceTexture(texId)
565
+ st.setDefaultBufferSize(spec.width, spec.height)
566
+ val surface = Surface(st)
567
+ val player = MediaPlayer()
568
+ val ok = runCatching {
569
+ player.setDataSource(spec.filePath)
570
+ player.setSurface(surface)
571
+ player.isLooping = true
572
+ player.setVolume(0f, 0f)
573
+ player.prepare()
574
+ player.start()
575
+ }.isSuccess
576
+ if (!ok) {
577
+ runCatching { player.release() }
578
+ surface.release()
579
+ st.release()
580
+ GLES20.glDeleteTextures(1, intArrayOf(texId), 0)
581
+ return null
582
+ }
583
+ return VideoLayer(
584
+ player, texId, st, surface,
585
+ gl.ndcRectOf(spec.x, spec.y, spec.width, spec.height, oesV = true),
586
+ )
587
+ }
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Lớp CHỮ: bitmap vẽ bằng Paint (font hệ thống — tiếng Việt đủ dấu) upload
593
+ * lên MỘT texture tái dùng theo id. `update` chỉ chạy khi spec ĐỔI (so
594
+ * equality data class) — mỗi pha bóng một lần render ~vài trăm µs, giữa hai
595
+ * pha là no-op tuyệt đối. Hình học nền = bội fontSize (0.45/0.25/0.22),
596
+ * PHẢI khớp OVERLAY_TEXT_BG của @pickleball/shared (preview editor cùng công thức).
597
+ */
598
+ internal class TextLayer(val id: String, private val texId: Int) : RuntimeLayer() {
599
+ private var applied: TextSpec? = null
600
+ private var rect: FloatArray = FloatArray(16)
601
+ private var opacity = 1f
602
+ private var hasContent = false
603
+
604
+ /** Render + upload nếu spec đổi. Trả false khi không dựng được bitmap. */
605
+ fun update(gl: GlCompositor, spec: TextSpec): Boolean {
606
+ if (spec == applied) return true
607
+ val fontSize = spec.fontSizePx.coerceAtLeast(8).toFloat()
608
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
609
+ textSize = fontSize
610
+ typeface = if (spec.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
611
+ color = spec.colorArgb
612
+ }
613
+ val fm = paint.fontMetrics
614
+ val textW = paint.measureText(spec.text)
615
+ val hasBg = spec.bgArgb != null
616
+ val padX = if (hasBg) fontSize * 0.45f else 0f
617
+ val padY = if (hasBg) fontSize * 0.25f else 0f
618
+ val w = kotlin.math.ceil(textW + padX * 2).toInt().coerceAtLeast(1)
619
+ val h = kotlin.math.ceil((fm.descent - fm.ascent) + padY * 2).toInt().coerceAtLeast(1)
620
+ if (w > 4096 || h > 1024) return false
621
+ val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
622
+ val canvas = Canvas(bitmap)
623
+ if (hasBg) {
624
+ val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = spec.bgArgb!! }
625
+ val radius = fontSize * 0.22f
626
+ canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radius, radius, bgPaint)
627
+ }
628
+ canvas.drawText(spec.text, padX, padY - fm.ascent, paint)
629
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId)
630
+ // texImage2D (không phải SUB): bề rộng chữ đổi theo nội dung ("7"→"10"),
631
+ // cấp phát lại storage trên CÙNG texture id — layer không bị dựng lại.
632
+ GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
633
+ bitmap.recycle()
634
+ // Mỏ neo theo align: x là mép trái/tâm/mép phải của ẢNH (ảnh ôm trọn nền).
635
+ val left = when (spec.align) {
636
+ "center" -> spec.x - w / 2
637
+ "right" -> spec.x - w
638
+ else -> spec.x
639
+ }.coerceAtLeast(0)
640
+ rect = gl.ndcRectOf(left, spec.y, w, h)
641
+ opacity = spec.opacity
642
+ applied = spec
643
+ hasContent = true
644
+ return true
645
+ }
646
+
647
+ override fun draw(gl: GlCompositor) {
648
+ if (!hasContent) return
649
+ // Opacity CẢ LỚP qua uAlpha: shader nhân cả rgb lẫn alpha — đúng phép
650
+ // fade premultiplied, không cần bake vào bitmap như đường CPU bên iOS.
651
+ gl.drawLayerQuad(gl.rgbaProgram, GLES20.GL_TEXTURE_2D, texId, rect, IDENTITY, opacity)
652
+ }
653
+
654
+ override fun release() {
655
+ GLES20.glDeleteTextures(1, intArrayOf(texId), 0)
656
+ }
657
+ }
658
+
659
+ // MARK: - Helpers
660
+
661
+ private fun runBlockingOnGl(block: () -> Unit) {
662
+ if (Thread.currentThread() === thread) {
663
+ block()
664
+ return
665
+ }
666
+ val latch = CountDownLatch(1)
667
+ handler.post {
668
+ try {
669
+ block()
670
+ } finally {
671
+ latch.countDown()
672
+ }
673
+ }
674
+ latch.await()
675
+ }
676
+
677
+ private fun buildProgram(vs: String, fs: String): Int {
678
+ fun compile(type: Int, src: String): Int {
679
+ val id = GLES20.glCreateShader(type)
680
+ GLES20.glShaderSource(id, src)
681
+ GLES20.glCompileShader(id)
682
+ val ok = IntArray(1)
683
+ GLES20.glGetShaderiv(id, GLES20.GL_COMPILE_STATUS, ok, 0)
684
+ check(ok[0] != 0) { "shader lỗi: ${GLES20.glGetShaderInfoLog(id)}" }
685
+ return id
686
+ }
687
+ val program = GLES20.glCreateProgram()
688
+ GLES20.glAttachShader(program, compile(GLES20.GL_VERTEX_SHADER, vs))
689
+ GLES20.glAttachShader(program, compile(GLES20.GL_FRAGMENT_SHADER, fs))
690
+ GLES20.glLinkProgram(program)
691
+ val ok = IntArray(1)
692
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, ok, 0)
693
+ check(ok[0] != 0) { "link program lỗi: ${GLES20.glGetProgramInfoLog(program)}" }
694
+ return program
695
+ }
696
+
697
+ private companion object {
698
+ val IDENTITY = FloatArray(16).also { android.opengl.Matrix.setIdentityM(it, 0) }
699
+
700
+ /**
701
+ * Full khung cho CAMERA sample BUFFER THÔ (identity, không qua ma trận ST):
702
+ * hàng-0 của buffer = mép TRÊN ảnh → v=0 phải nằm ở ĐỈNH quad. Gắn v=0
703
+ * xuống đáy là "lộn ngược chốc đầu" (đo thật 26/08 tối, vòng thử 2).
704
+ */
705
+ val FULL_RECT_NDC = floatArrayOf(
706
+ -1f, -1f, 0f, 1f,
707
+ 1f, -1f, 1f, 1f,
708
+ -1f, 1f, 0f, 0f,
709
+ 1f, 1f, 1f, 0f,
710
+ )
711
+
712
+ /** Như trên nhưng texcoord xoay 180° — (u,v) → (1−u, 1−v). */
713
+ val FULL_RECT_NDC_ROT180 = floatArrayOf(
714
+ -1f, -1f, 1f, 1f,
715
+ 1f, -1f, 0f, 1f,
716
+ -1f, 1f, 1f, 0f,
717
+ 1f, 1f, 0f, 0f,
718
+ )
719
+
720
+ val vertexBuf: FloatBuffer = ByteBuffer.allocateDirect(16 * 4)
721
+ .order(ByteOrder.nativeOrder()).asFloatBuffer()
722
+
723
+ const val VERTEX_SHADER = """
724
+ attribute vec2 aPos;
725
+ attribute vec2 aTex;
726
+ uniform mat4 uTexMatrix;
727
+ varying vec2 vTex;
728
+ void main() {
729
+ gl_Position = vec4(aPos, 0.0, 1.0);
730
+ vTex = (uTexMatrix * vec4(aTex, 0.0, 1.0)).xy;
731
+ }
732
+ """
733
+
734
+ const val FS_OES = """
735
+ #extension GL_OES_EGL_image_external : require
736
+ precision mediump float;
737
+ varying vec2 vTex;
738
+ uniform samplerExternalOES uTexture;
739
+ uniform float uAlpha;
740
+ void main() {
741
+ gl_FragColor = texture2D(uTexture, vTex) * uAlpha;
742
+ }
743
+ """
744
+
745
+ const val FS_RGBA = """
746
+ precision mediump float;
747
+ varying vec2 vTex;
748
+ uniform sampler2D uTexture;
749
+ uniform float uAlpha;
750
+ void main() {
751
+ gl_FragColor = texture2D(uTexture, vTex) * uAlpha;
752
+ }
753
+ """
754
+ }
755
+ }