@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- 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
|
+
}
|