@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,110 @@
1
+ package dev.pictor.video
2
+
3
+ /**
4
+ * Which presentation surface is live, and which one the player is attached to.
5
+ *
6
+ * A player attaches to a `Surface` once and keeps it for life. When the surface
7
+ * is replaced — a remounted editor, a second `create` — the player goes on
8
+ * rendering into the one it was born with: it decodes, it releases frames, it
9
+ * reports no error, and the live texture receives nothing. On a Pixel 7 that
10
+ * read as metadata offered climbing 2018 → 2164 over five seconds while
11
+ * availability sat at 180 and the renderer pulled zero frames, with a black
12
+ * preview and playback state READY.
13
+ *
14
+ * Nothing in that is an error condition, so it needs a comparison rather than a
15
+ * try/catch. Two facts are enough: which surface is live, and which one an
16
+ * attachment was made against.
17
+ *
18
+ * **Attachment is the adapter's job, not the host's.** A caller that has to
19
+ * remember to re-attach will forget, and the symptom is a black rectangle with
20
+ * healthy counters. So creating a surface attaches it, and this type only
21
+ * decides *whether* an attachment is needed.
22
+ *
23
+ * Deliberately free of Android types and of any player, so the whole decision is
24
+ * testable on the JVM without a device, a mock, or a codec.
25
+ */
26
+ internal class SurfaceAttachment {
27
+
28
+ /** No surface, and no attachment. Distinct from any real generation. */
29
+ companion object {
30
+ const val NONE = 0
31
+ }
32
+
33
+ /** The generation of the surface that is currently live. */
34
+ @Volatile
35
+ var live: Int = NONE
36
+ private set
37
+
38
+ /** The generation the player is attached to, or [NONE]. */
39
+ @Volatile
40
+ var attached: Int = NONE
41
+ private set
42
+
43
+ /** Attachments actually performed. A gate on needless output rebuilds. */
44
+ @Volatile
45
+ var attachments: Int = 0
46
+ private set
47
+
48
+ /** Surfaces that have ever been live. Only ever increases. */
49
+ @Volatile
50
+ var created: Int = 0
51
+ private set
52
+
53
+ /**
54
+ * A new surface is live. Returns its generation.
55
+ *
56
+ * Never reuses a number, so a teardown arriving late can always be told
57
+ * apart from the surface it is trying to retire.
58
+ */
59
+ fun surfaceLive(): Int {
60
+ created += 1
61
+ live = created
62
+ return live
63
+ }
64
+
65
+ /** Whether an attachment made at [generation] is still the live surface. */
66
+ fun isStale(generation: Int): Boolean = generation == NONE || generation != live
67
+
68
+ /** Whether the player needs attaching to the live surface right now. */
69
+ fun needsAttach(): Boolean = live != NONE && isStale(attached)
70
+
71
+ /**
72
+ * Record an attachment to whatever is live. Returns whether it acted.
73
+ *
74
+ * Idempotent: re-attaching an unchanged surface makes Media3 drop and
75
+ * rebuild its output, which costs frames at exactly the wrong moment.
76
+ */
77
+ fun attach(): Boolean {
78
+ if (!needsAttach()) return false
79
+ attached = live
80
+ attachments += 1
81
+ return true
82
+ }
83
+
84
+ /**
85
+ * Retire the surface of [generation]. Returns whether it was the live one.
86
+ *
87
+ * **Generation-checked, and this is the point.** Teardown of an old surface
88
+ * can arrive after a newer one is already live and attached — the old
89
+ * screen's cleanup running after the new screen's setup, which is the
90
+ * ordinary case rather than a race. Clearing unconditionally there would
91
+ * detach the surface that is actually on screen and reproduce the very
92
+ * defect this exists to prevent.
93
+ *
94
+ * Idempotent: retiring something already retired changes nothing.
95
+ */
96
+ fun retire(generation: Int): Boolean {
97
+ if (generation == NONE || generation != live) return false
98
+ live = NONE
99
+ attached = NONE
100
+ return true
101
+ }
102
+
103
+ /** Test seam. Production never rewinds. */
104
+ fun resetForTest() {
105
+ live = NONE
106
+ attached = NONE
107
+ attachments = 0
108
+ created = 0
109
+ }
110
+ }
@@ -0,0 +1,171 @@
1
+ package dev.pictor.video
2
+
3
+ import android.graphics.Bitmap
4
+ import android.media.MediaMetadataRetriever
5
+ import android.net.Uri
6
+ import java.io.ByteArrayOutputStream
7
+ import java.util.concurrent.ConcurrentHashMap
8
+ import java.util.concurrent.Executors
9
+ import java.util.concurrent.Future
10
+ import java.util.concurrent.atomic.AtomicLong
11
+
12
+ /**
13
+ * Still frames for the timeline strip.
14
+ *
15
+ * `MediaMetadataRetriever`, not a second `ExoPlayer`: a retriever opens a source
16
+ * once and seeks around it cheaply, while a player instance per strip would
17
+ * hold a codec, a surface and a buffer pool for something that draws a 64-pixel
18
+ * square. The playback path is untouched by any of this — one retriever per
19
+ * source, on a background pool, and the presentation surface never hears about
20
+ * it.
21
+ *
22
+ * **Cancellation is real, not advisory.** A strip that scrolls fast abandons
23
+ * far more requests than it uses. Each request is a `Future` that the JS side
24
+ * can cancel by token, and a cancelled one stops before it decodes rather than
25
+ * decoding and discarding.
26
+ *
27
+ * Retrievers are pooled per source and closed explicitly. Nothing here is
28
+ * reachable from a finalizer.
29
+ */
30
+ internal object ThumbnailDecoder {
31
+
32
+ /**
33
+ * Two threads.
34
+ *
35
+ * Enough to keep decoding while one request waits on I/O, few enough that a
36
+ * scroll cannot start twenty concurrent seeks and starve the player's own
37
+ * threads — which share the same cores as the thing the user is watching.
38
+ */
39
+ private val pool = Executors.newFixedThreadPool(2) { runnable ->
40
+ Thread(runnable, "pictor-thumbs").apply { isDaemon = true }
41
+ }
42
+
43
+ private val retrievers = ConcurrentHashMap<String, MediaMetadataRetriever>()
44
+ private val running = ConcurrentHashMap<Long, Future<*>>()
45
+ private val results = ConcurrentHashMap<Long, String>()
46
+ private val failures = ConcurrentHashMap<Long, String>()
47
+ private val nextToken = AtomicLong(1)
48
+
49
+ @Volatile var decoded: Long = 0; private set
50
+ @Volatile var cancelled: Long = 0; private set
51
+ @Volatile var failed: Long = 0; private set
52
+
53
+ /**
54
+ * Start a decode. Returns a token to poll or cancel with.
55
+ *
56
+ * Deliberately not a Promise across the bridge. A strip issues and abandons
57
+ * requests in bursts, and a rejected Promise per abandoned request is both
58
+ * noisy and hard to cancel; a token the caller owns makes "stop this one"
59
+ * expressible.
60
+ */
61
+ @JvmStatic
62
+ fun request(uri: String, sourceTimeUs: Long, widthPx: Int, context: android.content.Context): Long {
63
+ val token = nextToken.getAndIncrement()
64
+ val future = pool.submit {
65
+ if (Thread.currentThread().isInterrupted) return@submit
66
+ val bitmap = runCatching { decode(uri, sourceTimeUs, widthPx, context) }
67
+ .getOrElse { error ->
68
+ failures[token] = error.message ?: error.toString()
69
+ failed += 1
70
+ null
71
+ }
72
+ if (bitmap == null) {
73
+ running.remove(token)
74
+ return@submit
75
+ }
76
+ if (Thread.currentThread().isInterrupted) {
77
+ bitmap.recycle()
78
+ running.remove(token)
79
+ return@submit
80
+ }
81
+ // A data URI, because the renderer builds its own image from bytes
82
+ // and this way the adapter never holds a decoded frame the JS side
83
+ // might also be holding. The strip's images are small by
84
+ // construction — a 64px thumbnail is a few kilobytes.
85
+ results[token] = encode(bitmap)
86
+ bitmap.recycle()
87
+ decoded += 1
88
+ running.remove(token)
89
+ }
90
+ running[token] = future
91
+ return token
92
+ }
93
+
94
+ /** Stop a request. Interrupts it if it has started. */
95
+ @JvmStatic
96
+ fun cancel(token: Long) {
97
+ val future = running.remove(token) ?: return
98
+ if (future.cancel(true)) cancelled += 1
99
+ results.remove(token)
100
+ failures.remove(token)
101
+ }
102
+
103
+ /** The result, or null while it is still running. Consumes it. */
104
+ @JvmStatic
105
+ fun take(token: Long): String? = results.remove(token)
106
+
107
+ /** Why a request failed, or null. Consumes it. */
108
+ @JvmStatic
109
+ fun takeError(token: Long): String? = failures.remove(token)
110
+
111
+ /** Whether a token is still being worked on. */
112
+ @JvmStatic
113
+ fun isRunning(token: Long): Boolean = running.containsKey(token)
114
+
115
+ private fun decode(
116
+ uri: String,
117
+ sourceTimeUs: Long,
118
+ widthPx: Int,
119
+ context: android.content.Context,
120
+ ): Bitmap? {
121
+ val retriever = retrievers.getOrPut(uri) {
122
+ MediaMetadataRetriever().apply {
123
+ val parsed = Uri.parse(uri)
124
+ if (parsed.scheme == "http" || parsed.scheme == "https") {
125
+ setDataSource(uri, HashMap())
126
+ } else {
127
+ setDataSource(context, parsed)
128
+ }
129
+ }
130
+ }
131
+ // OPTION_CLOSEST_SYNC, not OPTION_CLOSEST: a strip wants speed and
132
+ // approximate times, and exact-frame extraction decodes every frame
133
+ // from the previous keyframe forward — seconds of work for one 64px
134
+ // square.
135
+ val full = retriever.getFrameAtTime(sourceTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
136
+ ?: return null
137
+ if (full.width <= widthPx) return full
138
+ val height = (full.height.toLong() * widthPx / full.width).toInt().coerceAtLeast(1)
139
+ val scaled = Bitmap.createScaledBitmap(full, widthPx, height, true)
140
+ if (scaled !== full) full.recycle()
141
+ return scaled
142
+ }
143
+
144
+ private fun encode(bitmap: Bitmap): String {
145
+ val bytes = ByteArrayOutputStream()
146
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 70, bytes)
147
+ return android.util.Base64.encodeToString(bytes.toByteArray(), android.util.Base64.NO_WRAP)
148
+ }
149
+
150
+ /** Close the retriever for one source. Explicit; never from a finalizer. */
151
+ @JvmStatic
152
+ fun releaseSource(uri: String) {
153
+ retrievers.remove(uri)?.let { runCatching { it.release() } }
154
+ }
155
+
156
+ /** Close everything. Called when the editor goes away. */
157
+ @JvmStatic
158
+ fun releaseAll() {
159
+ for (future in running.values) future.cancel(true)
160
+ running.clear()
161
+ results.clear()
162
+ failures.clear()
163
+ for (retriever in retrievers.values) runCatching { retriever.release() }
164
+ retrievers.clear()
165
+ }
166
+
167
+ /** decoded, cancelled, failed, running, open retrievers. */
168
+ @JvmStatic
169
+ fun counters(): LongArray =
170
+ longArrayOf(decoded, cancelled, failed, running.size.toLong(), retrievers.size.toLong())
171
+ }
@@ -0,0 +1,394 @@
1
+ package dev.pictor.video
2
+
3
+ import android.graphics.SurfaceTexture
4
+ import android.os.Handler
5
+ import android.os.HandlerThread
6
+ import android.view.Surface
7
+
8
+ /**
9
+ * The Java half of the persistent presentation surface.
10
+ *
11
+ * A `SurfaceTexture` binds to the *calling thread's* current GL context, so
12
+ * every method here is called from native code running on the UI runtime with
13
+ * Skia's context current — the arrangement the context probe verified. Nothing
14
+ * here may be called from anywhere else.
15
+ *
16
+ * The `Surface` is registered so a player can render into it without any of
17
+ * this crossing Pictor's public API: the adapter hands Media3 an Android
18
+ * `Surface`, and Pictor sees a texture id and a transform.
19
+ */
20
+ internal object VideoSurface {
21
+
22
+ /**
23
+ * The surface the current session renders into.
24
+ *
25
+ * Set from native once the texture exists, read by the player when it
26
+ * attaches. Volatile because those are different threads.
27
+ */
28
+ @Volatile
29
+ private var current: Surface? = null
30
+
31
+ /** Which surface is live and which one the player holds. */
32
+ @JvmStatic
33
+ val attachment = SurfaceAttachment()
34
+
35
+ /**
36
+ * Whether a frame is waiting, as a level rather than an edge.
37
+ *
38
+ * `onFrameAvailable` fires once and is gone; a consumer that was disarmed at
39
+ * that instant — backgrounded for the system picker — never learns it
40
+ * happened, and a paused player produces no second edge. See [FramePending].
41
+ */
42
+ @JvmStatic
43
+ val framePending = FramePending()
44
+
45
+ /**
46
+ * The texture the live generation belongs to.
47
+ *
48
+ * The listener resolves its generation *through* this, at callback time. A
49
+ * generation captured when the listener was registered is a snapshot of a
50
+ * value that moves: if `watch` were ever called before `register`, or the
51
+ * surface replaced between them, every subsequent frame would be tagged
52
+ * with a number that was already wrong — and `mark` would reject them all,
53
+ * silently, as belonging to a retired generation.
54
+ *
55
+ * Resolving from the texture also gets the late case right for free. A
56
+ * listener detached a moment too late fires on a texture that is no longer
57
+ * the live one, so it is rejected — which is what should happen, and what
58
+ * reading a bare "current generation" at callback time would get backwards
59
+ * by crediting the frame to whatever replaced it.
60
+ */
61
+ @Volatile
62
+ private var liveTexture: SurfaceTexture? = null
63
+
64
+ /**
65
+ * Notified the instant a surface becomes live.
66
+ *
67
+ * This is what makes attachment the adapter's responsibility rather than a
68
+ * step a host has to remember. A caller that must call `attachSurface()`
69
+ * after every `create` will eventually not, and the symptom is a black
70
+ * rectangle with entirely healthy counters — no exception, no error state,
71
+ * nothing to catch. Set once, when the player is built.
72
+ */
73
+ @Volatile
74
+ @JvmStatic
75
+ var onSurfaceLive: (() -> Unit)? = null
76
+
77
+ /** The live surface's generation, or [SurfaceAttachment.NONE]. */
78
+ @JvmStatic
79
+ val generation: Int
80
+ get() = attachment.live
81
+
82
+ /**
83
+ * A new surface is live; attach it.
84
+ *
85
+ * Returns the generation, which the caller keeps so a later teardown can
86
+ * prove it is retiring its own surface rather than someone else's.
87
+ */
88
+ @JvmStatic
89
+ fun register(surface: Surface): Int {
90
+ current = surface
91
+ val generation = attachment.surfaceLive()
92
+ framePending.adopt(generation)
93
+ // Attach here, not at the next playlist change: between the two the
94
+ // player would be rendering into the previous texture.
95
+ onSurfaceLive?.invoke()
96
+ return generation
97
+ }
98
+
99
+ @JvmStatic
100
+ fun currentSurface(): Surface? = current
101
+
102
+ /** Whether an attachment made at [attachedGeneration] is still live. */
103
+ @JvmStatic
104
+ fun isStale(attachedGeneration: Int): Boolean = attachment.isStale(attachedGeneration)
105
+
106
+ @JvmStatic
107
+ fun clear() {
108
+ current = null
109
+ }
110
+
111
+ /** Attach a `SurfaceTexture` to an existing external texture. */
112
+ @JvmStatic
113
+ fun create(textureId: Int, width: Int, height: Int): SurfaceTexture {
114
+ val texture = SurfaceTexture(textureId)
115
+ texture.setDefaultBufferSize(width, height)
116
+ return texture
117
+ }
118
+
119
+ @JvmStatic
120
+ fun surfaceOf(texture: SurfaceTexture): Surface = Surface(texture)
121
+
122
+ /**
123
+ * Pull the newest frame into the texture. Returns whether it actually did.
124
+ *
125
+ * `updateTexImage` does **not** always throw when it fails. If Skia's GL
126
+ * context is not current on this thread the platform logs
127
+ * `LegacySurfaceTexture is not attached` and returns quietly, and a caller
128
+ * that had already consumed the availability signal has then lost the frame
129
+ * entirely — the picture holds until the *next* frame arrives, which at 30
130
+ * fps is a 33 ms stutter with no error to explain it.
131
+ *
132
+ * So success is measured rather than assumed: the texture's timestamp
133
+ * advances only when a buffer was really latched. The caller puts the
134
+ * availability signal back when this returns false, turning a dropped frame
135
+ * into a one-tick retry.
136
+ */
137
+ @JvmStatic
138
+ fun update(texture: SurfaceTexture, generation: Int): Boolean {
139
+ // One owner, and it stops owning the moment its surface is retired or
140
+ // replaced. A worklet ticking an old host object would otherwise latch
141
+ // into a texture nobody is drawing — the invalid call, removed rather
142
+ // than caught.
143
+ if (attachment.isStale(generation)) {
144
+ updatesSkipped += 1
145
+ return false
146
+ }
147
+ updatesAttempted += 1
148
+ // Read before the acquisition, not after: `updateTexImage` can run for
149
+ // long enough that a frame is announced during it, and that frame is
150
+ // not one this latch took.
151
+ val mark = framePending.observe(generation)
152
+ val before = texture.timestamp
153
+ return try {
154
+ texture.updateTexImage()
155
+ // Success is measured, not assumed: updateTexImage returns quietly
156
+ // when the context is not current, and the timestamp only advances
157
+ // when a buffer was really latched.
158
+ val after = texture.timestamp
159
+ val latched = after != before
160
+ if (latched) {
161
+ updatesSucceeded += 1
162
+ // Coalesced to the newest this call could have taken: one
163
+ // buffer was taken and everything it skipped is gone, but a
164
+ // frame announced *during* the call is still waiting.
165
+ framePending.consumeUpTo(generation, mark)
166
+ } else {
167
+ // Nothing was taken, so nothing is accounted for. Leaving the
168
+ // counter alone is the restore.
169
+ updatesFailed += 1
170
+ }
171
+ latched
172
+ } catch (error: RuntimeException) {
173
+ updatesFailed += 1
174
+ false
175
+ }
176
+ }
177
+
178
+ /**
179
+ * What the one owner did, so a failure stays visible.
180
+ *
181
+ * `failed` must remain reportable rather than swallowed: a latch that
182
+ * silently does nothing looks exactly like a paused video.
183
+ */
184
+ @Volatile @JvmStatic var updatesAttempted: Long = 0; private set
185
+ @Volatile @JvmStatic var updatesSucceeded: Long = 0; private set
186
+ @Volatile @JvmStatic var updatesSkipped: Long = 0; private set
187
+ @Volatile @JvmStatic var updatesFailed: Long = 0; private set
188
+
189
+ /** Retained name for the failure count, which is what a missed latch is. */
190
+ @JvmStatic
191
+ val missedLatches: Long
192
+ get() = updatesFailed
193
+
194
+ /** The 4×4 texture transform: crop, flip and rotation, in that encoding. */
195
+ @JvmStatic
196
+ fun transform(texture: SurfaceTexture): FloatArray {
197
+ val matrix = FloatArray(16)
198
+ texture.getTransformMatrix(matrix)
199
+ return matrix
200
+ }
201
+
202
+ @JvmStatic
203
+ fun timestamp(texture: SurfaceTexture): Long = texture.timestamp
204
+
205
+ /** Explicit, in order, on the thread that owns them. Never from a finalizer. */
206
+ @JvmStatic
207
+ fun release(texture: SurfaceTexture, surface: Surface, generation: Int) {
208
+ releases += 1
209
+ // Generation-checked. A screen's teardown routinely runs *after* the
210
+ // next screen's setup, so an old surface can be released while a newer
211
+ // one is already live and attached. Clearing unconditionally there
212
+ // would detach what is actually on screen — the very defect this
213
+ // machinery exists to prevent. Idempotent for the same reason.
214
+ if (attachment.retire(generation)) current = null
215
+ // The only thing permitted to discard a pending frame: the texture it
216
+ // belongs to is going away, so latching it would pull from one nobody
217
+ // is drawing. Disarming the consumer must never do this.
218
+ framePending.retire(generation)
219
+ // Generation-checked like everything above it. A screen's teardown
220
+ // routinely runs after the next screen's setup, and this thread is
221
+ // shared by every session — quitting it while retiring an *old* texture
222
+ // leaves the live listener with a dead looper, which stops
223
+ // `onFrameAvailable` for good with no exception anywhere.
224
+ val wasLive = texture === liveTexture
225
+ if (wasLive) liveTexture = null
226
+ // Listener first: nothing may arrive once the surface is gone.
227
+ runCatching { texture.setOnFrameAvailableListener(null) }
228
+ runCatching { surface.release() }
229
+ runCatching { texture.release() }
230
+ if (wasLive) {
231
+ runCatching { frames?.quitSafely() }
232
+ threadQuits += 1
233
+ frames = null
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Raise a flag when a frame lands, rather than doing work on the producer's
239
+ * thread. The UI runtime consumes it on its own tick.
240
+ */
241
+ /**
242
+ * One thread for the surface's whole life, not one per session.
243
+ *
244
+ * `onFrameAvailable` used to arrive on the main looper, which put UI
245
+ * scheduling delay inside the delivery measurement and did avoidable work
246
+ * on the thread that draws. This thread is created with the surface and
247
+ * quits with it, so sessions come and go without touching it.
248
+ */
249
+ @Volatile
250
+ private var frames: HandlerThread? = null
251
+
252
+ /*
253
+ Delivery bookkeeping.
254
+
255
+ `available` says frames stopped arriving; it cannot say whether the
256
+ listener was cleared, the thread that carries it was quit, or the producer
257
+ simply stopped. Those need opposite fixes, and the difference is three
258
+ counters and one liveness read.
259
+ */
260
+ @Volatile var watches: Long = 0
261
+ private set
262
+ @Volatile var releases: Long = 0
263
+ private set
264
+ @Volatile var threadQuits: Long = 0
265
+ private set
266
+
267
+ /** Whether the thread `onFrameAvailable` is delivered on can still run. */
268
+ @JvmStatic
269
+ fun deliveryAlive(): Boolean = frames?.isAlive == true
270
+
271
+ @JvmStatic
272
+ fun delivery(): LongArray =
273
+ longArrayOf(
274
+ watches,
275
+ releases,
276
+ threadQuits,
277
+ if (deliveryAlive()) 1 else 0,
278
+ // The acquisition side of the boundary, separated from the
279
+ // notification side. `announced` counts callbacks the listener
280
+ // actually received; `attempted`/`succeeded` count what the GL
281
+ // thread did with them. A gap between announced and attempted is a
282
+ // bookkeeping fault; a gap between attempted and succeeded is an
283
+ // acquisition fault; equality with a flat `announced` puts the
284
+ // fault before the callback.
285
+ framePending.announced,
286
+ framePending.rejected,
287
+ framePending.depth(),
288
+ updatesAttempted,
289
+ updatesSucceeded,
290
+ updatesFailed,
291
+ updatesSkipped,
292
+ )
293
+
294
+ @JvmStatic
295
+ fun watch(texture: SurfaceTexture, flag: java.util.concurrent.atomic.AtomicBoolean) {
296
+ val thread = frames ?: HandlerThread("pictor-frame-available")
297
+ .also { it.start(); frames = it }
298
+ liveTexture = texture
299
+ watches += 1
300
+ texture.setOnFrameAvailableListener(
301
+ { arrived ->
302
+ // Records availability and signals; nothing more. updateTexImage
303
+ // and every Skia call stay on the UI/GL runtime, because that is
304
+ // where the context lives.
305
+ FrameClock.markAvailable()
306
+ // Resolved now, from the texture the frame actually arrived on —
307
+ // never from a number captured when the listener was registered.
308
+ framePending.mark(generationOf(arrived))
309
+ flag.set(true)
310
+ },
311
+ Handler(thread.looper),
312
+ )
313
+ }
314
+
315
+ /**
316
+ * Which generation a texture belongs to, at the moment of asking.
317
+ *
318
+ * [SurfaceAttachment.NONE] for anything that is not the live texture, which
319
+ * is how a late listener's frame is rejected rather than credited to the
320
+ * surface that replaced it.
321
+ */
322
+ @JvmStatic
323
+ fun generationOf(texture: SurfaceTexture): Int =
324
+ if (texture === liveTexture) attachment.live else SurfaceAttachment.NONE
325
+
326
+ /**
327
+ * Stable identity of the producer side, for proving who is talking to whom.
328
+ *
329
+ * A consumer holding a stale object cannot be detected by comparing
330
+ * *values*: a captured surface that was `undefined` and a live one both
331
+ * answer "no frame pending", and every counter stays consistent while the
332
+ * two halves talk past each other. Identity is the only thing that
333
+ * distinguishes them, so it is published and recorded on both sides.
334
+ */
335
+ @JvmStatic
336
+ val controllerId: Int
337
+ get() = System.identityHashCode(this)
338
+
339
+ /** Identity of the pending counters the listener writes to. */
340
+ @JvmStatic
341
+ val pendingId: Int
342
+ get() = System.identityHashCode(framePending)
343
+
344
+ /** Whether a frame is waiting for [generation]. Level-triggered. */
345
+ @JvmStatic
346
+ fun hasPendingFrame(generation: Int): Boolean = framePending.pending(generation)
347
+
348
+ /** Pending-ness, depth and generation as one reading. See [FramePending.snapshot]. */
349
+ @JvmStatic
350
+ fun pendingSnapshot(generation: Int): Long = framePending.snapshot(generation)
351
+
352
+ /** The pending frame's intended presentation time, for scheduling the latch. */
353
+ @JvmStatic
354
+ fun pendingDueNs(): Long = FrameClock.pendingDueNs()
355
+
356
+ /**
357
+ * The generation the pending counters are keeping for.
358
+ *
359
+ * Published so a consumer can see *why* it is idle: pending for generation 2
360
+ * while the worklet asks about 3 is a completely different fault from
361
+ * nothing having arrived, and the two are indistinguishable from a single
362
+ * idle count.
363
+ */
364
+ @JvmStatic
365
+ val pendingGeneration: Int
366
+ get() = framePending.generation
367
+
368
+ /** Frames announced, ever. For the overlay. */
369
+ @JvmStatic
370
+ val framesAnnounced: Long
371
+ get() = framePending.announced
372
+
373
+ /** Announcements dropped because their generation was already retired. */
374
+ @JvmStatic
375
+ val framesRejected: Long
376
+ get() = framePending.rejected
377
+
378
+ /** Frames waiting right now. Usually 0 or 1; larger after a background. */
379
+ @JvmStatic
380
+ val pendingDepth: Long
381
+ get() = framePending.depth()
382
+
383
+ /**
384
+ * Stop callbacks before anything they touch goes away.
385
+ *
386
+ * Detaching is not optional on source replacement: a callback that lands
387
+ * after the generation moved would stamp availability for a frame of a clip
388
+ * that no longer exists.
389
+ */
390
+ @JvmStatic
391
+ fun unwatch(texture: SurfaceTexture) {
392
+ texture.setOnFrameAvailableListener(null)
393
+ }
394
+ }