@pictor-sdk/video-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,695 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.opengl.EGL14
5
+ import android.opengl.GLES20
6
+ import androidx.media3.common.VideoFrameProcessingException
7
+ import androidx.media3.common.util.GlProgram
8
+ import androidx.media3.common.util.Size
9
+ import androidx.media3.common.util.UnstableApi
10
+ import androidx.media3.effect.BaseGlShaderProgram
11
+ import androidx.media3.effect.GlEffect
12
+ import java.util.concurrent.atomic.AtomicLong
13
+
14
+ /**
15
+ * The gate's effect: a pass-through that proves the boundary is usable.
16
+ *
17
+ * Transformer owns the decode, the timing, the encode and the mux. This sits in
18
+ * the middle of its video chain and must come out the other side with the
19
+ * picture unchanged — which is what makes it a *gate* rather than a feature: any
20
+ * difference beyond the codec's own noise is this code's fault, and there is
21
+ * nowhere else for it to come from.
22
+ *
23
+ * ## Borrowed, never owned
24
+ *
25
+ * The input texture and the output framebuffer belong to Transformer. Nothing
26
+ * here deletes or retains either beyond the callback: `drawFrame` reads the
27
+ * input texture id and writes to whatever framebuffer
28
+ * [BaseGlShaderProgram] has bound, and holds neither afterwards. The only
29
+ * objects this class owns are its own [GlProgram] and its own instrumentation,
30
+ * and those are released exactly once, on the thread that made them.
31
+ *
32
+ * ## GL state is restored
33
+ *
34
+ * Transformer's chain expects the state it left. Viewport, blend, the bound
35
+ * texture and the active unit are captured on entry and put back on exit — a
36
+ * chain that draws correctly and leaves blending enabled corrupts whatever runs
37
+ * after it, and the failure appears in someone else's effect.
38
+ *
39
+ * ## It executes a scene, when given one
40
+ *
41
+ * With a [SceneProgram] it performs the scene's `clear` and then its identity
42
+ * `video` command. Without one it is the bare blit that established the frozen
43
+ * codec control — the two must produce identical pixels for an identity scene,
44
+ * which is exactly the gate.
45
+ *
46
+ * ## Not yet Skia
47
+ *
48
+ * This is the first half of the V3.3 gate. It establishes that the hook works,
49
+ * that presentation timestamps carry through untouched, that the expected EGL
50
+ * context is current on the frame-processing thread, and that release happens
51
+ * once — before a second Skia context is introduced into the same thread.
52
+ * Skia's draw replaces the blit below; everything around it is what has to be
53
+ * true either way.
54
+ */
55
+ @UnstableApi
56
+ internal class SkiaPassThroughEffect(
57
+ private val program: SceneProgram? = null,
58
+ /**
59
+ * Fail deliberately, to prove cleanup.
60
+ *
61
+ * `-1` never fails. `0` fails after GL initialisation and before the first
62
+ * frame — the window where a program exists and nothing has drawn, which is
63
+ * the leak a naive teardown misses. A positive number fails after that many
64
+ * frames, mid-run, with an encoder and a muxer already holding state.
65
+ */
66
+ private val failAfterFrames: Int = -1,
67
+ /** A compiled program, selected per frame by presentation timestamp. */
68
+ private val sceneProgram: SceneProgramNative? = null,
69
+ /**
70
+ * Where this shard sits on the timeline, compiled in TypeScript.
71
+ *
72
+ * Native's whole contribution to placement is one addition. The offset is
73
+ * the accumulated length of everything before this clip, and accumulating it
74
+ * is exactly the arithmetic that must not exist twice.
75
+ */
76
+ private val timelineOffsetUs: Long = 0,
77
+ /** Rasters, already loaded and checked before Transformer started. */
78
+ private val overlayResources: OverlayResources? = null,
79
+ /**
80
+ * Fail during overlay drawing, after N draws.
81
+ *
82
+ * `0` fails on the first draw — after the texture exists but before anything
83
+ * has used it, which is the window where an upload could leak. A positive
84
+ * number fails mid-sequence with textures bound and a partial frame drawn.
85
+ */
86
+ private val overlayFailAfterDraws: Int = -1,
87
+ ) : GlEffect {
88
+
89
+ val overlayUploads = AtomicLong(0)
90
+ val overlayDraws = AtomicLong(0)
91
+ val overlayTexturesFreed = AtomicLong(0)
92
+ private val shaderPrograms = AtomicLong(0)
93
+ private val frames = AtomicLong(0)
94
+ private val releases = AtomicLong(0)
95
+ private val timestamps = StringBuilder()
96
+ private val glErrorCodes = mutableSetOf<Int>()
97
+ /** The EGL context seen on the frame-processing thread, as a handle. */
98
+ @Volatile private var contextHandle: Long = 0
99
+ /** The thread the frame processor called us on. */
100
+ @Volatile private var threadId: Long = 0
101
+ @Volatile private var threadName: String = ""
102
+ @Volatile private var threadChanges: Int = 0
103
+ @Volatile private var contextChanges: Int = 0
104
+ /** Distinct segments the program selected, and when each was first entered. */
105
+ private val segmentsSeen = mutableSetOf<Int>()
106
+ private val transitions = mutableListOf<String>()
107
+ @Volatile private var shader: PassThroughProgram? = null
108
+
109
+ fun frameCount(): Long = frames.get()
110
+
111
+ fun releaseCount(): Long = releases.get()
112
+
113
+ /** Every distinct `glGetError` seen. Empty means the chain stayed clean. */
114
+ fun glErrors(): String = synchronized(glErrorCodes) { glErrorCodes.joinToString(",") }
115
+
116
+ /** Presentation timestamps, in order, as the effect received them. */
117
+ fun timestamps(): String = synchronized(timestamps) { timestamps.toString() }
118
+
119
+ /** Times the current EGL context changed under the effect. Must stay 0. */
120
+ fun contextChangeCount(): Int = contextChanges
121
+
122
+ /** GL programs created and deleted, and the thread each happened on. */
123
+ fun programTrace(): String = synchronized(segmentsSeen) {
124
+ "segments=" + segmentsSeen.sorted().joinToString("/") +
125
+ " enteredAt=" + transitions.joinToString(",")
126
+ }
127
+
128
+ fun lifecycle(): String =
129
+ "created=${glCreated.get()} deleted=${glDeleted.get()} " +
130
+ "createThread=$createThread deleteThread=$deleteThread"
131
+
132
+ private val glCreated = AtomicLong(0)
133
+ private val glDeleted = AtomicLong(0)
134
+ @Volatile private var createThread: Long = 0
135
+ @Volatile private var deleteThread: Long = 0
136
+
137
+ /**
138
+ * Who Transformer ran the effect on, and in which GL context.
139
+ *
140
+ * The invocation-boundary question turns on this: if the composition lives
141
+ * on another thread with another EGL context, then handing it this frame is
142
+ * a cross-context transfer with a synchronisation problem, not a function
143
+ * call.
144
+ */
145
+ fun boundary(): String =
146
+ "ctx=$contextHandle thread=$threadId/$threadName ctxChanges=$contextChanges threadChanges=$threadChanges"
147
+
148
+ override fun toGlShaderProgram(context: Context, useHdr: Boolean): BaseGlShaderProgram {
149
+ val made = PassThroughProgram(useHdr)
150
+ shader = made
151
+ shaderPrograms.incrementAndGet()
152
+ return made
153
+ }
154
+
155
+ /**
156
+ * Release the effect's own instrumentation.
157
+ *
158
+ * The shader program is released by Transformer, on the frame-processing
159
+ * thread, through `BaseGlShaderProgram.release`. Releasing it from here as
160
+ * well would be a second release on the wrong thread, which is the failure
161
+ * this counter exists to catch.
162
+ */
163
+ fun release() {
164
+ releases.incrementAndGet()
165
+ }
166
+
167
+ private inner class PassThroughProgram(useHdr: Boolean) : BaseGlShaderProgram(useHdr, 1) {
168
+ private var glProgram: GlProgram? = null
169
+ private var released = false
170
+ private var outputWidth = 0
171
+ private var outputHeight = 0
172
+ private var projectWidth = 0
173
+ private var projectHeight = 0
174
+
175
+ /** Reused, so a per-frame transform allocates nothing. */
176
+ private val matrixScratch = floatArrayOf(1f, 0f, 0f, 1f, 0f, 0f)
177
+ private var opacity = 1f
178
+ private var segmentIndex = -1
179
+
180
+ /**
181
+ * One texture per resource, uploaded on first use and reused after.
182
+ *
183
+ * Not per frame: a large raster re-uploaded 300 times is 300 transfers
184
+ * of bytes that never changed. Freed in [release], which Transformer
185
+ * calls on the thread that created them.
186
+ */
187
+ private val overlayTextures = HashMap<String, Int>()
188
+ private var overlayProgram: GlProgram? = null
189
+
190
+ override fun configure(inputWidth: Int, inputHeight: Int): Size {
191
+ // Project size, from the scene. The conversion below needs the space
192
+ // the matrix was written in, not the one it is rendered into.
193
+ projectWidth = sceneProgram?.projectWidth ?: program?.projectWidth ?: inputWidth
194
+ projectHeight = sceneProgram?.projectHeight ?: program?.projectHeight ?: inputHeight
195
+ /*
196
+ The output is the **project's** size, not this input's.
197
+
198
+ Each clip is encoded in its own pass and the parts are then
199
+ concatenated by copying samples, which writes every part's payload
200
+ under the *first* part's track format. A shard that sized its
201
+ output to its own source therefore produced a different
202
+ elementary stream — measured on a 540x960 clip beside two
203
+ 1080x1920 ones: `960x540, level 512, csd-0 22 bytes` copied under
204
+ `1920x1080, level 2048, csd-0 23 bytes`. A decoder configured from
205
+ the declared codec-specific data cannot correctly decode those
206
+ samples, and the finished file reports one format for everything
207
+ inside it, so nothing downstream can notice.
208
+
209
+ Every shard must therefore agree on one output format, and the
210
+ project is the only thing that defines it. Sampling still uses the
211
+ input texture at whatever size it arrived — the video quad spans
212
+ the output and reads the texture across its whole extent, so a
213
+ smaller source is scaled up here rather than encoded small and
214
+ reinterpreted later.
215
+
216
+ Scene matrices and overlay quads are unaffected: both are in
217
+ project space and converted to clip space, which is what makes
218
+ this change a format decision rather than a geometry one.
219
+ */
220
+ outputWidth = projectWidth
221
+ outputHeight = projectHeight
222
+ // GL initialisation, eagerly, so "after init and before the first
223
+ // frame" is a window that actually exists to fail in.
224
+ if (glProgram == null) {
225
+ glProgram = makeProgram()
226
+ glCreated.incrementAndGet()
227
+ createThread = Thread.currentThread().id
228
+ }
229
+ return Size(outputWidth, outputHeight)
230
+ }
231
+
232
+ override fun drawFrame(inputTexId: Int, presentationTimeUs: Long) {
233
+ if (failAfterFrames >= 0 && frames.get() >= failAfterFrames) {
234
+ throw VideoFrameProcessingException(
235
+ RuntimeException("injected failure at frame ${'$'}{frames.get()}"),
236
+ )
237
+ }
238
+ recordContext()
239
+ recordTimestamp(presentationTimeUs)
240
+
241
+ // Captured, then restored below. Transformer's chain expects the
242
+ // state it left.
243
+ val savedViewport = IntArray(4)
244
+ GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, savedViewport, 0)
245
+ val savedActive = IntArray(1)
246
+ GLES20.glGetIntegerv(GLES20.GL_ACTIVE_TEXTURE, savedActive, 0)
247
+ val blendWasOn = GLES20.glIsEnabled(GLES20.GL_BLEND)
248
+
249
+ try {
250
+ /*
251
+ The scene's `clear`, executed rather than assumed redundant.
252
+
253
+ An identity video covers the output, so the clear is invisible
254
+ here — and it is still performed, because whether coverage
255
+ holds is not this interpreter's judgement to make. When a
256
+ transform or a partial opacity arrives it will be the only
257
+ thing standing between a fade and the previous frame.
258
+ */
259
+ /*
260
+ A program selects per frame; a single scene applies to all of
261
+ them. Selection is a binary search over the compiled intervals
262
+ — native learns which interval this timestamp falls in and
263
+ nothing else about the timeline.
264
+ */
265
+ val compiled = sceneProgram
266
+ if (compiled != null) {
267
+ val index = compiled.segmentAt(timelineOffsetUs + presentationTimeUs)
268
+ if (index < 0) {
269
+ throw VideoFrameProcessingException(
270
+ RuntimeException("no segment covers " + presentationTimeUs),
271
+ )
272
+ }
273
+ /*
274
+ The segment's first timestamp is recorded; its window is
275
+ not checked against a decoder.
276
+
277
+ There is no per-frame window signal in this path.
278
+ `FrameClock.windowIndex` belongs to the **preview**
279
+ player, and Transformer's effect receives a presentation
280
+ timestamp and nothing else — comparing the two produced a
281
+ confident false failure at the seam, from a counter
282
+ another pipeline had left behind. Deriving the window from
283
+ the timestamp instead would be timeline arithmetic in
284
+ native, which is the thing this design exists to avoid.
285
+
286
+ What *is* observable is which segment each timestamp
287
+ selected, so the transitions are recorded and checked
288
+ against the program's own boundaries by the caller.
289
+ */
290
+ synchronized(segmentsSeen) {
291
+ if (segmentsSeen.add(index)) {
292
+ transitions.add(index.toString() + "@" + (timelineOffsetUs + presentationTimeUs))
293
+ }
294
+ }
295
+ compiled.matrixAt(index, matrixScratch)
296
+ opacity = compiled.opacityAt(index)
297
+ segmentIndex = index
298
+ val c = compiled.clearColorAt(index)
299
+ GLES20.glClearColor(
300
+ ((c shr 16) and 0xff) / 255f,
301
+ ((c shr 8) and 0xff) / 255f,
302
+ (c and 0xff) / 255f,
303
+ ((c ushr 24) and 0xff) / 255f,
304
+ )
305
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
306
+ }
307
+ val scene = program
308
+ if (scene != null) {
309
+ opacity = 1f
310
+ matrixScratch[0] = 1f
311
+ matrixScratch[1] = 0f
312
+ matrixScratch[2] = 0f
313
+ matrixScratch[3] = 1f
314
+ matrixScratch[4] = 0f
315
+ matrixScratch[5] = 0f
316
+ val c = scene.clearColor
317
+ GLES20.glClearColor(
318
+ ((c shr 16) and 0xff) / 255f,
319
+ ((c shr 8) and 0xff) / 255f,
320
+ (c and 0xff) / 255f,
321
+ ((c ushr 24) and 0xff) / 255f,
322
+ )
323
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
324
+ }
325
+ val prog = glProgram ?: makeProgram().also { glProgram = it }
326
+ prog.use()
327
+ prog.setSamplerTexIdUniform("uTexSampler", inputTexId, 0)
328
+ prog.setFloatsUniform("uTransform", clipSpaceTransform())
329
+ prog.setFloatUniform("uOpacity", opacity)
330
+ prog.setFloatUniform("uUseAttrCoord", 0f)
331
+ prog.setFloatUniform("uPremultiplied", 0f)
332
+ prog.setBufferAttribute("aTexCoord", OVERLAY_TEX_COORDS, 2)
333
+ /*
334
+ Source-over, onto the clear the scene already drew.
335
+
336
+ The order is the rule: background first, then the transformed
337
+ video at the clip's opacity. A partly transparent clip reveals
338
+ that background — the file stays opaque and never becomes an
339
+ alpha video.
340
+
341
+ Blending is left off at full opacity. `1·src + 0·dst` is src, so
342
+ it would be a no-op arithmetically, and skipping it keeps the
343
+ established max-delta-0 floor a property of the same code path
344
+ rather than of a rounding mode.
345
+ */
346
+ val blending = opacity < 1f
347
+ if (blending) {
348
+ GLES20.glEnable(GLES20.GL_BLEND)
349
+ GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
350
+ } else {
351
+ GLES20.glDisable(GLES20.GL_BLEND)
352
+ }
353
+ prog.bindAttributesAndUniforms()
354
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
355
+ drawOverlays()
356
+ } catch (e: Exception) {
357
+ throw VideoFrameProcessingException(e)
358
+ } finally {
359
+ if (blendWasOn) GLES20.glEnable(GLES20.GL_BLEND) else GLES20.glDisable(GLES20.GL_BLEND)
360
+ GLES20.glActiveTexture(savedActive[0])
361
+ GLES20.glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3])
362
+ recordGlError()
363
+ }
364
+ frames.incrementAndGet()
365
+ }
366
+
367
+ override fun release() {
368
+ // Once, on the thread Transformer calls it on — which is the thread
369
+ // the program was made on.
370
+ if (released) return
371
+ released = true
372
+ if (overlayTextures.isNotEmpty()) {
373
+ val ids = overlayTextures.values.toIntArray()
374
+ runCatching { GLES20.glDeleteTextures(ids.size, ids, 0) }
375
+ overlayTexturesFreed.addAndGet(ids.size.toLong())
376
+ overlayTextures.clear()
377
+ }
378
+ runCatching { overlayProgram?.delete() }
379
+ overlayProgram = null
380
+ runCatching { glProgram?.delete() }
381
+ if (glProgram != null) {
382
+ glDeleted.incrementAndGet()
383
+ deleteThread = Thread.currentThread().id
384
+ }
385
+ glProgram = null
386
+ super.release()
387
+ }
388
+
389
+ /**
390
+ * The scene matrix, converted to clip space. The only conversion applied.
391
+ *
392
+ * `resolveVideoScene` emits an affine over normalised project
393
+ * coordinates, and preview applies it in output pixels as
394
+ * `translate(tx·W, ty·H) · translate(W/2, H/2) · L · translate(-W/2, -H/2)`.
395
+ * Clip space is square and pixels are not, so the off-diagonal terms
396
+ * carry the aspect ratio — without it a rotation would shear on a
397
+ * non-square output, and would still look plausible on a square one.
398
+ *
399
+ * The matrix already carries the box's anchor, so it is applied and
400
+ * **not centred again** — the surplus `(I − L)·centre` is what displaced
401
+ * a 0.55 scale by a half-extent and pushed a flip off the canvas.
402
+ *
403
+ * The terms below depend only on the **project** size. Output size
404
+ * cancels out of the conversion, which is the arithmetic saying the two
405
+ * spaces agree: a scene means the same thing whatever it is rendered
406
+ * into.
407
+ *
408
+ * Nothing else is applied here. The source's own orientation was settled
409
+ * upstream by Media3's frame processor, which is what keeps it innermost
410
+ * and this matrix outermost, exactly as preview orders them.
411
+ *
412
+ * Column-major, as GLSL wants it.
413
+ */
414
+ private fun clipSpaceTransform(): FloatArray {
415
+ val m = matrixScratch
416
+ val w = projectWidth.toFloat()
417
+ val h = projectHeight.toFloat()
418
+ if (w <= 0f || h <= 0f) return IDENTITY_MAT3
419
+ val a = m[0]
420
+ val b = m[1]
421
+ val c = m[2]
422
+ val d = m[3]
423
+ val tx = m[4]
424
+ val ty = m[5]
425
+ /*
426
+ Clip space is [-1, 1] with y up; project space is [0, size] with y
427
+ down. Both the scale and the origin shift, so the constant term is
428
+ not simply the translation: it also carries what the linear part
429
+ does to the corner the two spaces disagree about.
430
+
431
+ An earlier version used `2·tx/Wp` alone, which is the constant only
432
+ when the matrix is expressed as an offset from the centre. Under the
433
+ canonical convention `tx` is an absolute project coordinate, and
434
+ that shortcut put a 0.25 scale at 405..540 instead of 202..337 —
435
+ exactly twice the offset, the same failure the convention was
436
+ corrected to remove.
437
+ */
438
+ val ax = c * (h / w)
439
+ val by = b * (w / h)
440
+ val cx = a + ax + 2f * tx / w - 1f
441
+ val cy = 1f - by - d - 2f * ty / h
442
+ // x' = a·x − ax·y + cx
443
+ // y' = −by·x + d·y + cy
444
+ return floatArrayOf(
445
+ a, -by, 0f,
446
+ -ax, d, 0f,
447
+ cx, cy, 1f,
448
+ )
449
+ }
450
+
451
+ /**
452
+ * Overlays, after the video and in the resolver's z order.
453
+ *
454
+ * Placement mirrors preview exactly: the scene matrix positions the
455
+ * overlay's **centre**, and the measured half-extent that arrived with
456
+ * the resource turns that into a box. The four corners are computed in
457
+ * project units and converted to clip space directly — a quad needs no
458
+ * matrix conversion, and deriving one would be a second place for the
459
+ * aspect terms to disagree.
460
+ */
461
+ /** Deduplicates the trace above: one line per fact, not per frame. */
462
+ private val overlayTrace = java.util.Collections.synchronizedSet(HashSet<String>())
463
+
464
+ private fun drawOverlays() {
465
+ val compiled = sceneProgram ?: return
466
+ val resources = overlayResources
467
+ if (segmentIndex < 0) return
468
+ /*
469
+ One line per distinct segment, and per segment-and-overlay.
470
+
471
+ An overlay that does not appear has three possible explanations —
472
+ the segment carried no command, the command was carried but the
473
+ resource was absent, or it was drawn to a quad nobody can see —
474
+ and from outside the encoder they are indistinguishable. This
475
+ separates them at the only place that knows.
476
+ */
477
+ val draws = if (resources == null) emptyList() else compiled.overlaysAt(segmentIndex)
478
+ if (overlayTrace.add("seg$segmentIndex")) {
479
+ android.util.Log.i(
480
+ "PictorOverlay",
481
+ "segment=" + segmentIndex + " offsetUs=" + timelineOffsetUs +
482
+ " resources=" + (resources != null) +
483
+ " commands=" + (if (resources == null) -1 else compiled.overlaysAt(segmentIndex).size),
484
+ )
485
+ }
486
+ if (draws.isEmpty()) return
487
+ // Narrowed after the trace above, which is the only thing that
488
+ // needed to observe the null case.
489
+ val res = resources ?: return
490
+
491
+ val program = overlayProgram ?: GlProgram(VERTEX_SHADER, FRAGMENT_SHADER).also {
492
+ overlayProgram = it
493
+ }
494
+ val pw = projectWidth.toFloat()
495
+ val ph = projectHeight.toFloat()
496
+ GLES20.glEnable(GLES20.GL_BLEND)
497
+ for (draw in draws) {
498
+ val entry = res.entries[draw.resourceId] ?: throw VideoFrameProcessingException(
499
+ RuntimeException("overlay resource '" + draw.resourceId + "' was not preloaded"),
500
+ )
501
+ val texId = overlayTextures.getOrPut(draw.resourceId) { upload(entry) }
502
+ if (overlayFailAfterDraws >= 0 && overlayDraws.get() >= overlayFailAfterDraws) {
503
+ throw VideoFrameProcessingException(
504
+ RuntimeException("injected overlay failure after " + overlayDraws.get() + " draws"),
505
+ )
506
+ }
507
+ overlayDraws.incrementAndGet()
508
+ /*
509
+ The blend function follows what the producer declared.
510
+
511
+ Straight and premultiplied describe the same picture with
512
+ different stored numbers, and using one function on the other's
513
+ bytes darkens or haloes every antialiased edge — subtly enough
514
+ to look like a font-rendering difference rather than a bug.
515
+ */
516
+ if (entry.premultiplied) {
517
+ GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA)
518
+ } else {
519
+ GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
520
+ }
521
+ val m = draw.matrix
522
+ // Anchored on the logical size, laid out over the visual bounds.
523
+ val halfW = entry.projectWidth / 2f
524
+ val halfH = entry.projectHeight / 2f
525
+ val x0 = entry.boundsX - halfW
526
+ val y0 = entry.boundsY - halfH
527
+ val x1 = x0 + entry.boundsWidth
528
+ val y1 = y0 + entry.boundsHeight
529
+ val local = floatArrayOf(x0, y0, x1, y0, x0, y1, x1, y1)
530
+ val quad = FloatArray(8)
531
+ for (k in 0 until 4) {
532
+ val lx = local[k * 2]
533
+ val ly = local[k * 2 + 1]
534
+ val px = m[0] * lx + m[2] * ly + m[4]
535
+ val py = m[1] * lx + m[3] * ly + m[5]
536
+ quad[k * 2] = 2f * px / pw - 1f
537
+ quad[k * 2 + 1] = 1f - 2f * py / ph
538
+ }
539
+ if (overlayTrace.add("seg" + segmentIndex + ":" + draw.resourceId)) {
540
+ android.util.Log.i(
541
+ "PictorOverlay",
542
+ "draw segment=" + segmentIndex + " id=" + draw.overlayId +
543
+ " quad=[" + quad[0] + "," + quad[1] + " " + quad[6] + "," + quad[7] + "]" +
544
+ " logical=" + entry.projectWidth + "x" + entry.projectHeight +
545
+ " bounds=" + entry.boundsX + "," + entry.boundsY + "," +
546
+ entry.boundsWidth + "x" + entry.boundsHeight +
547
+ " project=" + pw + "x" + ph,
548
+ )
549
+ }
550
+ program.use()
551
+ program.setBufferAttribute("aFramePosition", quad, 2)
552
+ program.setBufferAttribute("aTexCoord", OVERLAY_TEX_COORDS, 2)
553
+ program.setSamplerTexIdUniform("uTexSampler", texId, 0)
554
+ program.setFloatsUniform("uTransform", IDENTITY_MAT3)
555
+ program.setFloatUniform("uOpacity", draw.opacity)
556
+ program.setFloatUniform("uUseAttrCoord", 1f)
557
+ program.setFloatUniform("uPremultiplied", if (entry.premultiplied) 1f else 0f)
558
+ program.bindAttributesAndUniforms()
559
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
560
+ }
561
+ }
562
+
563
+ private fun upload(entry: OverlayResources.Entry): Int {
564
+ val ids = IntArray(1)
565
+ GLES20.glGenTextures(1, ids, 0)
566
+ GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0])
567
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
568
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
569
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
570
+ GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
571
+ val buffer = java.nio.ByteBuffer.allocateDirect(entry.pixels.size)
572
+ .order(java.nio.ByteOrder.nativeOrder())
573
+ buffer.put(entry.pixels)
574
+ buffer.position(0)
575
+ GLES20.glTexImage2D(
576
+ GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
577
+ entry.rasterWidth, entry.rasterHeight, 0,
578
+ GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer,
579
+ )
580
+ overlayUploads.incrementAndGet()
581
+ return ids[0]
582
+ }
583
+
584
+ private fun makeProgram(): GlProgram {
585
+ val program = GlProgram(VERTEX_SHADER, FRAGMENT_SHADER)
586
+ program.setBufferAttribute(
587
+ "aFramePosition",
588
+ floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f),
589
+ 2,
590
+ )
591
+ return program
592
+ }
593
+ }
594
+
595
+ private fun recordContext() {
596
+ val current = Thread.currentThread()
597
+ if (threadId == 0L) {
598
+ threadId = current.id
599
+ threadName = current.name
600
+ } else if (threadId != current.id) {
601
+ threadChanges += 1
602
+ threadId = current.id
603
+ threadName = current.name
604
+ }
605
+ val handle = EGL14.eglGetCurrentContext().nativeHandle
606
+ if (contextHandle == 0L) {
607
+ contextHandle = handle
608
+ } else if (contextHandle != handle) {
609
+ // The frame processor is documented to run on one thread with one
610
+ // context. If that stops being true, every assumption about who owns
611
+ // what goes with it — so it is counted rather than assumed.
612
+ contextChanges += 1
613
+ contextHandle = handle
614
+ }
615
+ }
616
+
617
+ private fun recordTimestamp(us: Long) {
618
+ synchronized(timestamps) {
619
+ if (timestamps.isNotEmpty()) timestamps.append(',')
620
+ timestamps.append(us)
621
+ }
622
+ }
623
+
624
+ private fun recordGlError() {
625
+ var code = GLES20.glGetError()
626
+ while (code != GLES20.GL_NO_ERROR) {
627
+ synchronized(glErrorCodes) { glErrorCodes.add(code) }
628
+ code = GLES20.glGetError()
629
+ }
630
+ }
631
+
632
+ private companion object {
633
+ val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
634
+
635
+ /**
636
+ * TL, TR, BL, BR — matching the order the overlay quad is built in.
637
+ *
638
+ * `v = 0` at the top: a raster's first row is its top row, and the
639
+ * corner it belongs with is the one at project `y = 0`. Getting this
640
+ * backwards gives a vertically mirrored overlay that is otherwise
641
+ * perfectly placed, which is why the gate's synthetic pattern has four
642
+ * differently coloured quadrants.
643
+ */
644
+ val OVERLAY_TEX_COORDS = floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
645
+
646
+ const val VERTEX_SHADER = """
647
+ attribute vec4 aFramePosition;
648
+ attribute vec2 aTexCoord;
649
+ uniform mat3 uTransform;
650
+ uniform highp float uUseAttrCoord;
651
+ uniform highp float uPremultiplied;
652
+ varying vec2 vTexCoord;
653
+ void main() {
654
+ // The texture coordinate stays with the *original* corner, so the
655
+ // quad moves and the sampling does not. Pixels the quad no longer
656
+ // covers keep the clear colour instead of stale texture.
657
+ vTexCoord = mix(aFramePosition.xy * 0.5 + 0.5, aTexCoord, uUseAttrCoord);
658
+ vec3 p = uTransform * vec3(aFramePosition.xy, 1.0);
659
+ gl_Position = vec4(p.xy, 0.0, 1.0);
660
+ }
661
+ """
662
+
663
+ const val FRAGMENT_SHADER = """
664
+ precision mediump float;
665
+ uniform sampler2D uTexSampler;
666
+ uniform float uOpacity;
667
+ // Declared in both stages, and explicitly `highp` in both: a
668
+ // uniform is per-program, and a vertex `float` defaults to highp
669
+ // while a fragment one here would be mediump. The link then fails
670
+ // with "the precision does not match" rather than anything about
671
+ // the variable being undeclared.
672
+ uniform highp float uUseAttrCoord;
673
+ uniform highp float uPremultiplied;
674
+ varying vec2 vTexCoord;
675
+ void main() {
676
+ /*
677
+ Straight alpha, paired with SRC_ALPHA/ONE_MINUS_SRC_ALPHA.
678
+
679
+ Video carries no per-pixel alpha, so the clip's opacity is the
680
+ whole of it and the texture's own channel is ignored -- which
681
+ keeps that path bit-identical to before overlays existed. An
682
+ overlay raster is the opposite: its shape *is* its alpha, and
683
+ discarding it drew a solid rectangle where a soft one belonged.
684
+ `uUseAttrCoord` already distinguishes the two.
685
+ */
686
+ vec4 c = texture2D(uTexSampler, vTexCoord);
687
+ float a = mix(uOpacity, c.a * uOpacity, uUseAttrCoord);
688
+ // Premultiplied colour must be scaled by the same opacity its
689
+ // alpha is, or a faded overlay brightens instead of fading.
690
+ vec3 rgb = mix(c.rgb, c.rgb * uOpacity, uPremultiplied);
691
+ gl_FragColor = vec4(rgb, a);
692
+ }
693
+ """
694
+ }
695
+ }