@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,934 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import androidx.media3.common.AudioAttributes
8
+ import androidx.media3.common.C
9
+ import androidx.media3.common.MediaItem
10
+ import androidx.media3.common.PlaybackException
11
+ import androidx.media3.common.PlaybackParameters
12
+ import androidx.media3.common.Player
13
+ import androidx.media3.exoplayer.ExoPlayer
14
+ import androidx.media3.exoplayer.video.VideoFrameMetadataListener
15
+
16
+ /**
17
+ * Playback, by Media3.
18
+ *
19
+ * ExoPlayer owns decoding, buffering, audio focus, A/V synchronisation, seeking,
20
+ * speed and lifecycle. Pictor owns the document, the timeline and the Skia
21
+ * composition, and this class is only the translation between them.
22
+ *
23
+ * That division is the point of the change. The custom `MediaCodec`/`AudioTrack`
24
+ * backend reached one-frame synchronisation, and cost several slices on codec
25
+ * pacing, buffer ownership, async-flush races and a GL memory leak — none of
26
+ * which is Pictor's problem, and none of which accumulates the per-device
27
+ * workarounds ExoPlayer already carries.
28
+ *
29
+ * Video goes to the persistent `SurfaceTexture` from [VideoSurface], so the
30
+ * renderer keeps one external texture and one `SkImage` for the whole session.
31
+ *
32
+ * **No Media3 type leaves this class.** The module exposes numbers and strings;
33
+ * `ExoPlayer`, `MediaItem` and the rest stop here.
34
+ */
35
+ internal class Media3Player private constructor(
36
+ private val player: ExoPlayer,
37
+ val widthHint: Int,
38
+ val heightHint: Int,
39
+ ) {
40
+ @Volatile
41
+ private var released = false
42
+
43
+ /** Everything ExoPlayer is touched from is its own application thread. */
44
+ private val main = Handler(Looper.getMainLooper())
45
+
46
+ /**
47
+ * Publishes the position anchor, on the player's thread.
48
+ *
49
+ * One re-posted Runnable, never a fresh lambda. 50 ms is far coarser than a
50
+ * frame and that is fine: consumers extrapolate between anchors rather than
51
+ * asking the player, which is what keeps the render path free of a thread
52
+ * hop.
53
+ */
54
+ private val anchorPump = object : Runnable {
55
+ override fun run() {
56
+ if (released) return
57
+ PlaybackAnchor.publish(
58
+ PlaybackAnchor.Sample(
59
+ positionUs = player.currentPosition * 1000,
60
+ monotonicNs = System.nanoTime(),
61
+ rate = player.playbackParameters.speed,
62
+ playing = player.isPlaying,
63
+ buffering = player.playbackState == Player.STATE_BUFFERING,
64
+ discontinuity = false,
65
+ generation = FrameClock.generation,
66
+ windowIndex = player.currentMediaItemIndex,
67
+ ),
68
+ )
69
+ // The coordinator runs from here rather than from a timer of its
70
+ // own: this pump already exists, already runs on the player's
71
+ // thread, and is the cadence everything else about position uses.
72
+ PreviewSync.tick(::holdTransport)
73
+ durationUs = if (player.duration == C.TIME_UNSET) 0L else player.duration * 1000
74
+ ended = player.playbackState == Player.STATE_ENDED
75
+ ready = player.playbackState == Player.STATE_READY
76
+ videoWidth = player.videoSize.width
77
+ videoHeight = player.videoSize.height
78
+ // Cached here, on the player's own thread. Reading it from state()
79
+ // throws "Player is accessed on the wrong thread" — state() is
80
+ // called from JS and must touch nothing but volatiles.
81
+ playbackState = player.playbackState
82
+ // The active format, for fault classification. Reported here rather
83
+ // than from state(): touching the player off its own thread throws.
84
+ val format = player.videoFormat
85
+ videoFormat = if (format == null) {
86
+ ""
87
+ } else {
88
+ "${format.sampleMimeType} ${format.width}x${format.height}" +
89
+ " rot${format.rotationDegrees} @${format.frameRate}"
90
+ }
91
+ currentWindow = player.currentMediaItemIndex
92
+ FrameClock.windowIndex = currentWindow
93
+ windowPositionUs = player.currentPosition * 1000
94
+ windowCount = player.mediaItemCount
95
+ main.postDelayed(this, ANCHOR_INTERVAL_MS)
96
+ }
97
+ }
98
+
99
+ /** Called from the analytics listener on the player's thread. */
100
+ private fun decoderInitialised(name: String) {
101
+ decoderName = name
102
+ surfaceGeneration += 1
103
+ }
104
+
105
+ private fun startAnchor() {
106
+ main.post(anchorPump)
107
+ }
108
+
109
+ /**
110
+ * Report source failures. Registered on the player's thread after
111
+ * construction, because it writes this instance's fields.
112
+ *
113
+ * Without it a failed source is invisible: the player simply stops, and the
114
+ * state reads exactly like an idle player nobody asked to do anything. That
115
+ * is how a dead endpoint looked in the remote gate — behaviour that was
116
+ * correct and entirely unreported.
117
+ */
118
+ /**
119
+ * Take responsibility for attaching, for as long as this player exists.
120
+ *
121
+ * Registered when the player is built and cleared when it is released, so a
122
+ * surface created at any point — before this player, after it, or after a
123
+ * screen remount — reaches it without the host being involved.
124
+ */
125
+ private fun ownSurfaceAttachment() {
126
+ VideoSurface.onSurfaceLive = { attachSurface() }
127
+ attachSurface()
128
+ }
129
+
130
+ private fun attachErrorListener() {
131
+ main.post {
132
+ if (released) return@post
133
+ // Which decoder Media3 chose. Registered here, on the player's own
134
+ // thread and after construction, because it writes instance state —
135
+ // the companion cannot reach it.
136
+ player.addAnalyticsListener(
137
+ object : androidx.media3.exoplayer.analytics.AnalyticsListener {
138
+ override fun onVideoDecoderInitialized(
139
+ eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
140
+ name: String,
141
+ initializedTimestampMs: Long,
142
+ initializationDurationMs: Long,
143
+ ) {
144
+ decoderInitialised(name)
145
+ }
146
+
147
+ /*
148
+ The format the decoder is actually configured for, and the
149
+ size it actually produces.
150
+
151
+ Recorded from the player rather than inferred from the
152
+ device: "a Pixel 7 uses the Exynos decoder" is a guess
153
+ that happens to be checkable, and a guess that survives
154
+ because nobody checked it is how the last three
155
+ attributions in this area went wrong. A mixed-resolution
156
+ seam is also the one moment these two can disagree.
157
+ */
158
+ override fun onVideoInputFormatChanged(
159
+ eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
160
+ format: androidx.media3.common.Format,
161
+ decoderReuseEvaluation:
162
+ androidx.media3.exoplayer.DecoderReuseEvaluation?,
163
+ ) {
164
+ inputFormats += 1
165
+ inputWidth = format.width
166
+ inputHeight = format.height
167
+ inputRotation = format.rotationDegrees
168
+ // Whether Media3 kept the codec across the seam or built
169
+ // a new one. A reused codec that stops delivering and a
170
+ // replaced one that never starts are different faults.
171
+ decoderReuse = decoderReuseEvaluation?.result ?: -1
172
+ }
173
+
174
+ override fun onVideoSizeChanged(
175
+ eventTime: androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime,
176
+ size: androidx.media3.common.VideoSize,
177
+ ) {
178
+ outputSizes += 1
179
+ outputWidth = size.width
180
+ outputHeight = size.height
181
+ }
182
+ },
183
+ )
184
+ player.addListener(
185
+ object : Player.Listener {
186
+ /**
187
+ * A window change is a discontinuity for the frame clock.
188
+ *
189
+ * Frames queued for the outgoing clip must not be paired
190
+ * with a position in the incoming one — that is a seam that
191
+ * reports the wrong time for a few frames, which looks like
192
+ * clock drift and is not.
193
+ */
194
+ /**
195
+ * The picture and the mix are held together through a
196
+ * stall, and released together when they agree.
197
+ *
198
+ * See [PreviewSync]. Pausing only the mix was tried first
199
+ * and left a seam transient of about 200 ms, because the
200
+ * picture resumed the moment its decoder was ready while
201
+ * the mix was still moving.
202
+ */
203
+ override fun onPlaybackStateChanged(state: Int) {
204
+ val buffering = state == Player.STATE_BUFFERING
205
+ if (buffering == lastBufferingSeen) return
206
+ lastBufferingSeen = buffering
207
+ PreviewTrace.record("source.buffering") {
208
+ org.json.JSONObject()
209
+ .put("buffering", buffering)
210
+ .put("presentedUs", PreviewSync.presentedUs() ?: -1)
211
+ .put("decodedUs", PreviewSync.decodedUs(System.nanoTime()))
212
+ }
213
+ PreviewSync.onVideoBuffering(buffering, ::holdTransport)
214
+ }
215
+
216
+ override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
217
+ currentWindow = player.currentMediaItemIndex
218
+ // Published for the frame clock in the same callback the
219
+ // window actually changes in, so a frame offered after
220
+ // the seam is tagged with the window it belongs to.
221
+ FrameClock.windowIndex = currentWindow
222
+ applyWindowSpeed(currentWindow)
223
+ applyWindowMute(currentWindow)
224
+ transitions += 1
225
+ }
226
+
227
+ /** Which decoder Media3 chose, and when it changed. */
228
+ override fun onVideoSizeChanged(size: androidx.media3.common.VideoSize) {
229
+ videoWidth = size.width
230
+ videoHeight = size.height
231
+ }
232
+
233
+ override fun onPlayerError(error: PlaybackException) {
234
+ errorCount += 1
235
+ errorCode = error.errorCode
236
+ errorMessage = "${error.errorCodeName}: ${error.message ?: ""}"
237
+ // Media3's own classification, which is the only honest
238
+ // basis for retrying: a network drop recovers, a
239
+ // malformed container will fail identically forever.
240
+ errorRecoverable =
241
+ error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
242
+ error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
243
+ error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS
244
+ }
245
+ },
246
+ )
247
+ }
248
+ }
249
+
250
+ private fun onPlayer(block: ExoPlayer.() -> Unit) {
251
+ if (released) return
252
+ if (Looper.myLooper() == Looper.getMainLooper()) {
253
+ player.block()
254
+ } else {
255
+ main.post { if (!released) player.block() }
256
+ }
257
+ }
258
+
259
+ fun play() {
260
+ PreviewSync.onUserPlay()
261
+ // The dual-player preview shares the consumer, so it owns the same
262
+ // streaming flag: without it every frame would be paced against a
263
+ // schedule nobody set, or none would be paced at all.
264
+ FrameClock.setStreaming(true)
265
+ onPlayer {
266
+ // A hold may have taken the transport away; starting has to give it
267
+ // back or the press would do nothing.
268
+ transportHeld = false
269
+ play()
270
+ }
271
+ }
272
+
273
+ fun pause() {
274
+ PreviewSync.onUserStop("pause")
275
+ FrameClock.setStreaming(false)
276
+ onPlayer { pause() }
277
+ }
278
+
279
+ fun seekTo(positionUs: Long) {
280
+ // Before the seek, not after: frames already queued belong to the
281
+ // position being left, and pairing one with the new clip time would
282
+ // publish a position that never existed.
283
+ FrameClock.bump()
284
+ onPlayer { seekTo(positionUs / 1000) }
285
+ }
286
+
287
+ /**
288
+ * The user's preview rate, composed with whatever speed the clip carries.
289
+ *
290
+ * A 2x clip previewed at 0.5x plays at 1x. Overwriting the player's speed
291
+ * outright would silently discard the clip's own, which is part of the
292
+ * document and not a preview setting.
293
+ */
294
+ fun setRate(rate: Float) {
295
+ userRate = if (rate > 0f) rate else 1f
296
+ onPlayer {
297
+ applyWindowSpeed(currentMediaItemIndex)
298
+ applyWindowMute(currentMediaItemIndex)
299
+ }
300
+ }
301
+
302
+ /**
303
+ * A snapshot, for a throttled read. Never called per frame.
304
+ *
305
+ * Positions are microseconds, because that is Pictor's unit; Media3 works
306
+ * in milliseconds and the conversion belongs here rather than in JS.
307
+ */
308
+ /**
309
+ * A snapshot for a throttled read. **Never blocks.**
310
+ *
311
+ * Reads the anchor the player's own thread publishes rather than posting
312
+ * and waiting. The old version held a latch, which put a thread hop inside
313
+ * anything that called it — including the synchronisation measurement.
314
+ */
315
+ /**
316
+ * Every floating-point value the state carries, as a bridge-safe `Double`.
317
+ *
318
+ * `Map<String, Any>` boxes whatever it is given, and a Kotlin `Float` boxes
319
+ * to `java.lang.Float` — which the bridge does not convert. The field then
320
+ * arrives as `undefined` rather than as a wrong number, so a consumer that
321
+ * spreads defaults underneath still sees `undefined`, and the first
322
+ * `.toFixed` on it is fatal.
323
+ *
324
+ * Named so the conversion is a decision at the boundary rather than a
325
+ * `.toDouble()` remembered per call site — the two that were missing had one
326
+ * and did not survive, which is exactly the kind of thing an audit misses
327
+ * and a single funnel cannot.
328
+ */
329
+ private fun bridgeDouble(value: Float): Double = value.toDouble()
330
+
331
+ fun state(): Map<String, Any> {
332
+ if (released) return mapOf("released" to true)
333
+ val anchor = PlaybackAnchor.latest()
334
+ val counters = FrameClock.counters()
335
+ val granularity = PlaybackAnchor.granularity()
336
+ return mapOf(
337
+ "positionUs" to PlaybackAnchor.positionAt(System.nanoTime()),
338
+ "anchorPositionUs" to anchor.positionUs,
339
+ "durationUs" to durationUs,
340
+ "playing" to anchor.playing,
341
+ "buffering" to anchor.buffering,
342
+ "ended" to ended,
343
+ "ready" to ready,
344
+ "rate" to bridgeDouble(anchor.rate),
345
+ "width" to videoWidth,
346
+ "height" to videoHeight,
347
+ "generation" to counters[4],
348
+ "queueDepth" to FrameClock.depth(),
349
+ "overflowed" to counters[0],
350
+ "unpaired" to counters[1],
351
+ "stale" to counters[2],
352
+ "queueHighWater" to counters[3],
353
+ "threads" to threadCount(),
354
+ "nativeHeapBytes" to android.os.Debug.getNativeHeapAllocatedSize(),
355
+ "coalesced" to counters[5],
356
+ "anchorPublications" to granularity.publications,
357
+ "anchorChanges" to granularity.changes,
358
+ "anchorMaxStepUs" to granularity.maxStepUs,
359
+ "anchorMeanStepUs" to
360
+ (if (granularity.changes > 0) granularity.totalStepUs / granularity.changes else 0L),
361
+ "anchorMaxStaleNs" to granularity.maxStaleNs,
362
+ "windowIndex" to currentWindow,
363
+ "windowCount" to windowCount,
364
+ "windowPositionUs" to windowPositionUs,
365
+ "transitions" to transitions,
366
+ "missedLatches" to VideoSurface.missedLatches,
367
+ "updatesAttempted" to VideoSurface.updatesAttempted,
368
+ "updatesSucceeded" to VideoSurface.updatesSucceeded,
369
+ "updatesSkipped" to VideoSurface.updatesSkipped,
370
+ "updatesFailed" to VideoSurface.updatesFailed,
371
+ "framesOffered" to FrameClock.offered,
372
+ "framesAvailable" to FrameClock.available,
373
+ // The level, not the edge. `pendingDepth` is what a consumer that
374
+ // was disarmed when the frame arrived can still see.
375
+ "pendingDepth" to VideoSurface.pendingDepth,
376
+ "framesRejected" to VideoSurface.framesRejected,
377
+ "pendingGeneration" to VideoSurface.pendingGeneration,
378
+ "controllerId" to VideoSurface.controllerId.toLong(),
379
+ "pendingId" to VideoSurface.pendingId.toLong(),
380
+ "orphaned" to FrameClock.orphaned,
381
+ "seeksRequested" to seeksRequested,
382
+ "seeksAccepted" to seeksAccepted,
383
+ "pendingSeek" to pendingSeek,
384
+ "playbackState" to playbackState,
385
+ "videoFormat" to videoFormat,
386
+ "decoderName" to decoderName,
387
+ "inputFormats" to inputFormats,
388
+ "inputWidth" to inputWidth,
389
+ "inputHeight" to inputHeight,
390
+ "inputRotation" to inputRotation,
391
+ "decoderReuse" to decoderReuse,
392
+ "outputSizes" to outputSizes,
393
+ "outputWidth" to outputWidth,
394
+ "outputHeight" to outputHeight,
395
+ "surfaceGeneration" to surfaceGeneration,
396
+ "rateConstrained" to rateConstrained,
397
+ "requestedRate" to bridgeDouble(requestedRate),
398
+ "staleWindowSpeeds" to staleWindowSpeeds,
399
+ "windowSpeedCount" to (windowSpeeds?.size ?: 0).toLong(),
400
+ "effectiveRate" to bridgeDouble(
401
+ windowSpeeds?.getOrNull(currentWindow)?.times(userRate) ?: 1f,
402
+ ),
403
+ "surfaceAttachments" to VideoSurface.attachment.attachments.toLong(),
404
+ "attachedSurfaceGeneration" to VideoSurface.attachment.attached,
405
+ "liveSurfaceGeneration" to VideoSurface.attachment.live,
406
+ "lastPairedPtsUs" to (FrameClock.latestSample()?.get(FrameClock.Field.PTS_US) ?: -1L),
407
+ "errorCount" to errorCount,
408
+ "errorCode" to errorCode,
409
+ "errorMessage" to errorMessage,
410
+ "errorRecoverable" to errorRecoverable,
411
+ "released" to false,
412
+ )
413
+ }
414
+
415
+ /**
416
+ * Ask the player to try the source again.
417
+ *
418
+ * `prepare()` after an error is Media3's documented recovery: it re-resolves
419
+ * the source and resumes from the current position, so a network source that
420
+ * went away and came back needs nothing else. Recoverable errors are the
421
+ * only ones worth retrying — a malformed container will fail identically
422
+ * however many times it is asked — so callers decide, using [errorRecoverable].
423
+ */
424
+ fun retry() {
425
+ onPlayer {
426
+ clearError()
427
+ prepare()
428
+ }
429
+ }
430
+
431
+ /** A new attempt owns its own outcome; the previous failure is not it. */
432
+ private fun clearError() {
433
+ errorCode = 0
434
+ errorMessage = ""
435
+ errorRecoverable = false
436
+ }
437
+
438
+ // Written on the player's thread, read anywhere. Cheap fields the anchor
439
+ // does not carry.
440
+ @Volatile private var durationUs: Long = 0
441
+ @Volatile private var errorCode = 0
442
+ @Volatile private var errorMessage = ""
443
+ @Volatile private var errorRecoverable = false
444
+ @Volatile private var errorCount = 0L
445
+ @Volatile private var ended = false
446
+ @Volatile private var ready = false
447
+ @Volatile private var videoWidth = 0
448
+ @Volatile private var videoHeight = 0
449
+ @Volatile private var windowSpeeds: FloatArray? = null
450
+ /** The rate the user asked for, separate from the clip's own speed. */
451
+ @Volatile private var userRate = 1f
452
+ @Volatile private var currentWindow = 0
453
+ @Volatile private var windowPositionUs = 0L
454
+ @Volatile private var windowCount = 0
455
+ @Volatile private var transitions = 0L
456
+ private val mutedWindows = java.util.Collections.synchronizedSet(HashSet<Int>())
457
+
458
+ /**
459
+ * Silence every window, whatever the document says.
460
+ *
461
+ * Set while a mixed-audio preview is playing. The mix already contains the
462
+ * clips' own audio — it is compiled from the same program an export uses —
463
+ * so leaving the player audible would play the whole timeline twice, once
464
+ * mixed and once raw.
465
+ */
466
+ @Volatile private var sourceAudioMuted = false
467
+
468
+ /**
469
+ * Apply the mute for a window.
470
+ *
471
+ * `mutedWindows` was populated by [setPlaylist] and read by nothing, which
472
+ * meant a muted clip was silent in an export and audible in preview — the
473
+ * two disagreeing about the document, quietly. Applied here beside the
474
+ * speed, at the same three moments a window becomes current.
475
+ */
476
+ private fun applyWindowMute(windowIndex: Int) {
477
+ val silent = sourceAudioMuted || mutedWindows.contains(windowIndex)
478
+ val wanted = if (silent) 0f else 1f
479
+ if (player.volume != wanted) player.volume = wanted
480
+ publishedVolume = player.volume
481
+ }
482
+
483
+ /** Silence, or restore, the player's own audio. */
484
+ fun setSourceAudioMuted(muted: Boolean) {
485
+ sourceAudioMuted = muted
486
+ PreviewTrace.record("source.mute") { org.json.JSONObject().put("muted", muted) }
487
+ onPlayer { applyWindowMute(currentMediaItemIndex) }
488
+ }
489
+
490
+ /**
491
+ * The volume the source player is actually at.
492
+ *
493
+ * Published on every mute application rather than read on demand, because
494
+ * reading it needs the player's own thread and the trace samples from
495
+ * another one. A flag says what was asked for; this says what happened.
496
+ */
497
+ @Volatile private var publishedVolume: Float = 1f
498
+
499
+ /** Where each window begins on the project timeline. */
500
+ @Volatile private var windowTimelineStartUs: LongArray = LongArray(0)
501
+
502
+ /** The last buffering state seen, so a repeat is not acted on twice. */
503
+ @Volatile private var lastBufferingSeen = false
504
+
505
+ /**
506
+ * Whether the picture is held for a coordinated resume.
507
+ *
508
+ * `playWhenReady` is the transport, and a hold takes it away without
509
+ * touching what the person asked for — [PreviewSync] keeps that.
510
+ */
511
+ @Volatile private var transportHeld = false
512
+
513
+ /** Hold or release the picture's transport, on the player's own thread. */
514
+ private fun holdTransport(hold: Boolean) {
515
+ if (transportHeld == hold) return
516
+ transportHeld = hold
517
+ onPlayer { playWhenReady = !hold }
518
+ }
519
+
520
+ /** Window-local media time to project time, as `windowToProjectTime` does. */
521
+ internal fun speedFor(windowIndex: Int): Float {
522
+ val speeds = windowSpeeds ?: return -1f
523
+ return speeds.getOrNull(windowIndex)?.takeIf { it > 0f } ?: -1f
524
+ }
525
+
526
+ private fun globalFor(windowIndex: Int, localUs: Long): Long {
527
+ val starts = windowTimelineStartUs
528
+ val speeds = windowSpeeds
529
+ if (windowIndex < 0 || windowIndex >= starts.size) return localUs
530
+ val speed = speeds?.getOrNull(windowIndex)?.takeIf { it > 0f } ?: 1f
531
+ return starts[windowIndex] + Math.round(localUs / speed.toDouble())
532
+ }
533
+
534
+ /**
535
+ * Point the existing player at another source, without rebuilding it.
536
+ *
537
+ * `stop` + `clearMediaItems` + `setMediaItem` + `prepare` is the documented
538
+ * reuse path. Whether it actually resets session state, or accumulates,
539
+ * is the question heap attribution answers — so it exists to be measured,
540
+ * not because reuse is assumed better.
541
+ */
542
+ fun rebind(uriString: String) {
543
+ FrameClock.bump()
544
+ clearError()
545
+ val done = java.util.concurrent.CountDownLatch(1)
546
+ main.post {
547
+ runCatching {
548
+ player.stop()
549
+ player.clearMediaItems()
550
+ player.setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
551
+ player.prepare()
552
+ }
553
+ done.countDown()
554
+ }
555
+ done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
556
+ }
557
+
558
+ /**
559
+ * Replace the whole playlist, keeping the player, surface and texture.
560
+ *
561
+ * Two parallel arrays: the sources, and four numbers per entry — clipping
562
+ * start and end in microseconds, speed in milli-units, muted as 0/1. Flat,
563
+ * because this crosses the bridge and a map per clip would allocate a
564
+ * native-backed object per entry on every edit; the timeline can change on
565
+ * every frame of a trim drag.
566
+ *
567
+ * **The texture and the `SkImage` are untouched.** That is the whole point
568
+ * of a process-scoped presentation surface: reordering clips or deleting one
569
+ * is a playlist operation, not a reason to rebuild the GL objects the
570
+ * renderer holds.
571
+ *
572
+ * The generation bumps first, so frames already queued for the old playlist
573
+ * are rejected on arrival rather than paired with a time in the new one.
574
+ */
575
+ fun setPlaylist(
576
+ uris: List<String>,
577
+ bounds: DoubleArray,
578
+ startWindow: Int,
579
+ startPositionUs: Long,
580
+ ) {
581
+ FrameClock.bump()
582
+ clearError()
583
+ // Defensive only. Attachment happens when a surface becomes live; this
584
+ // is here so a playlist can never be loaded against a stale surface if
585
+ // that path is ever bypassed, not as the mechanism itself.
586
+ attachSurface()
587
+ val done = java.util.concurrent.CountDownLatch(1)
588
+ main.post {
589
+ runCatching {
590
+ val media = ArrayList<MediaItem>(uris.size)
591
+ val speeds = ArrayList<Float>(uris.size)
592
+ for ((index, uri) in uris.withIndex()) {
593
+ val base = index * FIELDS_PER_ITEM
594
+ val startUs = bounds[base].toLong()
595
+ val endUs = bounds[base + 1].toLong()
596
+ val speed = (bounds[base + 2] / 1000.0).toFloat()
597
+ val muted = bounds[base + 3] != 0.0
598
+ media.add(
599
+ MediaItem.Builder()
600
+ .setUri(Uri.parse(uri))
601
+ .setClippingConfiguration(
602
+ MediaItem.ClippingConfiguration.Builder()
603
+ // Media3 clips in milliseconds. The timeline is
604
+ // microseconds, so this is the one place the two
605
+ // units meet, and it rounds rather than truncates:
606
+ // truncation biases every trim earlier.
607
+ .setStartPositionMs((startUs + 500) / 1000)
608
+ .setEndPositionMs((endUs + 500) / 1000)
609
+ .build(),
610
+ )
611
+ .build(),
612
+ )
613
+ speeds.add(if (speed > 0f) speed else 1f)
614
+ if (muted) mutedWindows.add(index) else mutedWindows.remove(index)
615
+ }
616
+ windowSpeeds = speeds.toFloatArray()
617
+ /*
618
+ Where each window begins on the project timeline.
619
+
620
+ `player.currentPosition` is position *within the current
621
+ window*, so a consumer comparing it against a global time sees
622
+ it reset at every seam — measured as a 9.8 s apparent offset
623
+ when the trace first read it. The same cumulative sum
624
+ `windowToProjectTime` performs in `@pictor-sdk/video-core`, kept
625
+ here so a reader on another thread can convert without the
626
+ project.
627
+ */
628
+ val starts = LongArray(speeds.size)
629
+ var at = 0L
630
+ for (i in speeds.indices) {
631
+ starts[i] = at
632
+ val base = i * 4
633
+ val startUs = bounds[base].toLong()
634
+ val endUs = bounds[base + 1].toLong()
635
+ val speed = speeds[i]
636
+ at += Math.round((endUs - startUs) / speed.toDouble())
637
+ }
638
+ windowTimelineStartUs = starts
639
+ player.setMediaItems(media, startWindow, startPositionUs / 1000)
640
+ applyWindowSpeed(startWindow)
641
+ applyWindowMute(startWindow)
642
+ player.prepare()
643
+ }
644
+ done.countDown()
645
+ }
646
+ done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
647
+ }
648
+
649
+ /**
650
+ * Per-window playback speed.
651
+ *
652
+ * ExoPlayer's speed is a property of the player, not of an item, so a
653
+ * timeline whose clips differ in speed has to change it as windows change.
654
+ * Applied on transition rather than continuously — setting it every tick
655
+ * would fight the user's own rate control.
656
+ */
657
+ private fun applyWindowSpeed(windowIndex: Int) {
658
+ val speeds = windowSpeeds
659
+ // No playlist, or an index the playlist does not have.
660
+ //
661
+ // Both used to `return`, which leaves `playbackParameters` at whatever
662
+ // was last set. A cleared playlist therefore kept the rate of the
663
+ // playlist before it, and an index past the end kept the rate of a
664
+ // window that no longer exists — a stale value that outlives the thing
665
+ // that justified it. Unity is the only honest rate for a player with
666
+ // nothing to play, and it is a value rather than an absence, so it
667
+ // cannot be confused with "leave it alone".
668
+ if (speeds == null || speeds.isEmpty()) {
669
+ resetRate()
670
+ return
671
+ }
672
+ val speed = speeds.getOrNull(windowIndex)
673
+ if (speed == null) {
674
+ staleWindowSpeeds += 1
675
+ resetRate()
676
+ return
677
+ }
678
+ val requested = speed * userRate
679
+ // Clamped to the same range `composeRate` uses in @pictor-sdk/video-core.
680
+ // The two must agree: if the player quietly refused a rate the timeline
681
+ // still counted at, the picture would advance at one speed while the
682
+ // playhead moved at another — a drift that reads as a broken clock and
683
+ // is really an unreported refusal. Counted so it is visible rather than
684
+ // inferred.
685
+ requestedRate = requested
686
+ val effective = requested.coerceIn(MIN_RATE, MAX_RATE)
687
+ if (effective != requested) rateConstrained += 1
688
+ if (player.playbackParameters.speed != effective) {
689
+ player.playbackParameters = PlaybackParameters(effective)
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Put the player back to unity.
695
+ *
696
+ * Separate from `applyWindowSpeed` so "there is no rate to apply" is an
697
+ * action rather than a missing one. Never clamps, so it can never be the
698
+ * path that selects a ceiling.
699
+ */
700
+ private fun resetRate() {
701
+ if (player.playbackParameters.speed != 1f) {
702
+ player.playbackParameters = PlaybackParameters(1f)
703
+ }
704
+ }
705
+
706
+ /** Times a requested rate had to be clamped. Should stay zero in the UI. */
707
+ @Volatile private var rateConstrained = 0L
708
+
709
+ /**
710
+ * Times a window index had no speed.
711
+ *
712
+ * A playlist and a window index that disagree. Should stay zero; a non-zero
713
+ * value means the two were read at different moments.
714
+ */
715
+ @Volatile private var staleWindowSpeeds = 0L
716
+
717
+ /** The rate asked for before clamping, so a ceiling is distinguishable. */
718
+ @Volatile private var requestedRate = 1f
719
+
720
+ /**
721
+ * Point the player at the surface that is live now.
722
+ *
723
+ * Called by [VideoSurface] the instant a surface becomes live, so a host
724
+ * never has to remember it. Idempotent by generation: re-attaching an
725
+ * unchanged surface makes Media3 drop and rebuild its output, which costs
726
+ * frames at exactly the wrong moment.
727
+ */
728
+ private fun attachSurface() {
729
+ if (released || !VideoSurface.attachment.needsAttach()) return
730
+ val surface = VideoSurface.currentSurface() ?: return
731
+ val done = java.util.concurrent.CountDownLatch(1)
732
+ main.post {
733
+ runCatching {
734
+ if (!released && VideoSurface.attachment.attach()) {
735
+ player.setVideoSurface(surface)
736
+ }
737
+ }
738
+ done.countDown()
739
+ }
740
+ done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
741
+ }
742
+
743
+ /** Seek by window and offset, the player's own coordinates. */
744
+ fun seekToWindow(windowIndex: Int, positionUs: Long) {
745
+ FrameClock.bump()
746
+ seeksRequested += 1
747
+ pendingSeek = true
748
+ onPlayer {
749
+ seekTo(windowIndex, positionUs / 1000)
750
+ // Cleared on the player's thread once the seek has been accepted,
751
+ // so a `pendingSeek` that stays true is a seek the player never
752
+ // acted on — which is what a freeze would look like from here.
753
+ pendingSeek = false
754
+ seeksAccepted += 1
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Counters for the cross-window freeze soak.
760
+ *
761
+ * Deliberately cheap: three longs and a boolean, incremented on paths that
762
+ * already exist. A freeze that never reproduces still leaves these behind,
763
+ * so the next occurrence is diagnosable instead of anecdotal.
764
+ */
765
+ @Volatile private var seeksRequested = 0L
766
+ @Volatile private var seeksAccepted = 0L
767
+ @Volatile private var pendingSeek = false
768
+ @Volatile private var playbackState = 0
769
+ @Volatile private var videoFormat = ""
770
+ @Volatile private var decoderName = ""
771
+ @Volatile private var inputFormats = 0
772
+ @Volatile private var inputWidth = 0
773
+ @Volatile private var inputHeight = 0
774
+ @Volatile private var inputRotation = 0
775
+ @Volatile private var decoderReuse = -1
776
+ @Volatile private var outputSizes = 0
777
+ @Volatile private var outputWidth = 0
778
+ @Volatile private var outputHeight = 0
779
+ /** Bumped whenever a surface is attached, so re-attachment is visible. */
780
+ @Volatile private var surfaceGeneration = 0
781
+
782
+ /** End the source but keep the player, for the reuse configurations. */
783
+ fun stopKeepingPlayer() {
784
+ FrameClock.bump()
785
+ val done = java.util.concurrent.CountDownLatch(1)
786
+ main.post {
787
+ runCatching {
788
+ player.stop()
789
+ player.clearMediaItems()
790
+ }
791
+ done.countDown()
792
+ }
793
+ done.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
794
+ }
795
+
796
+ /** Live thread count, for leak attribution. Diagnostics only. */
797
+ fun threadCount(): Int = Thread.getAllStackTraces().size
798
+
799
+ /** Explicit, on the player's own thread. Never from a finalizer. */
800
+ fun release() {
801
+ if (live === this) live = null
802
+ if (released) return
803
+ released = true
804
+ val latch = java.util.concurrent.CountDownLatch(1)
805
+ main.post {
806
+ // Listener off before the surface, surface before the player.
807
+ // Media3 takes a non-null listener, so a no-op replaces ours
808
+ // rather than clearing it — the player is about to go anyway, and
809
+ // this stops FrameClock being fed during teardown.
810
+ runCatching {
811
+ player.setVideoFrameMetadataListener { _, _, _, _ -> }
812
+ }
813
+ VideoSurface.onSurfaceLive = null
814
+ runCatching { player.setVideoSurface(null) }
815
+ runCatching { player.release() }
816
+ main.removeCallbacks(anchorPump)
817
+ FrameClock.bump()
818
+ PlaybackAnchor.reset()
819
+ latch.countDown()
820
+ }
821
+ latch.await(RELEASE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
822
+ }
823
+
824
+ companion object {
825
+ /**
826
+ * The live player's volume, or null when there is none.
827
+ *
828
+ * For the trace, which needs to prove that source audio is silent
829
+ * while a mix is playing — a mute flag says what was intended, and
830
+ * this says what the player is at.
831
+ */
832
+ fun currentVolumeOrNull(): Double? = live?.publishedVolume?.toDouble()
833
+
834
+ /**
835
+ * The project-timeline position for a window-local one.
836
+ *
837
+ * Returns the input unchanged when there is no playlist to convert
838
+ * against, which is the honest answer for a single-item player.
839
+ */
840
+ fun globalPositionUs(windowIndex: Int, localUs: Long): Long =
841
+ live?.globalFor(windowIndex, localUs) ?: localUs
842
+
843
+ /**
844
+ * The speed the local-to-global conversion is dividing by.
845
+ *
846
+ * Exposed for the trace: the conversion has two factors — the rate the
847
+ * player is running at and the speed this window is declared with — and
848
+ * a project clock advancing at the wrong rate cannot be attributed to
849
+ * one of them without seeing both.
850
+ */
851
+ fun windowSpeedFor(windowIndex: Int): Float = live?.speedFor(windowIndex) ?: -1f
852
+
853
+ /** The player the module is holding, for read-only diagnostics. */
854
+ @Volatile internal var live: Media3Player? = null
855
+
856
+ private const val RELEASE_TIMEOUT_MS = 2_000L
857
+
858
+ /** Clipping start, clipping end, speed, muted — per playlist entry. */
859
+ private const val FIELDS_PER_ITEM = 4
860
+
861
+ /** Mirrors MIN/MAX_PLAYBACK_RATE in @pictor-sdk/video-core. */
862
+ private const val MIN_RATE = 0.1f
863
+ private const val MAX_RATE = 10f
864
+
865
+ /**
866
+ * How often the position anchor is refreshed.
867
+ *
868
+ * Coarser than a frame on purpose: consumers extrapolate between
869
+ * anchors, so this only bounds drift, and a tighter interval would buy
870
+ * nothing but wake-ups.
871
+ */
872
+ private const val ANCHOR_INTERVAL_MS = 50L
873
+
874
+ /**
875
+ * Build a player rendering into the registered presentation surface.
876
+ *
877
+ * The surface has to exist first: it is created on the UI runtime,
878
+ * where Skia's GL context lives, and only then handed here.
879
+ */
880
+ fun create(
881
+ context: Context,
882
+ uriString: String,
883
+ /** Configuration 1 of heap attribution: a player with no output. */
884
+ attachSurface: Boolean = true,
885
+ ): Media3Player {
886
+ val surface = if (attachSurface) {
887
+ VideoSurface.currentSurface()
888
+ ?: throw IllegalStateException(
889
+ "no presentation surface: create the external texture before preparing",
890
+ )
891
+ } else {
892
+ null
893
+ }
894
+ // A new source invalidates every queued frame.
895
+ FrameClock.bump()
896
+ val built = java.util.concurrent.CompletableFuture<ExoPlayer>()
897
+ Handler(Looper.getMainLooper()).post {
898
+ val player = ExoPlayer.Builder(context).build()
899
+ player.setAudioAttributes(
900
+ AudioAttributes.Builder()
901
+ .setUsage(C.USAGE_MEDIA)
902
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
903
+ .build(),
904
+ // Media3 handles audio focus, which the custom backend
905
+ // never did.
906
+ true,
907
+ )
908
+ // The clip's presentation time only exists here. It is paired
909
+ // with the renderer's frames positionally, which holds because
910
+ // this fires immediately before each frame is released to the
911
+ // surface and `onFrameAvailable` follows in the same order.
912
+ player.setVideoFrameMetadataListener(
913
+ VideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
914
+ FrameClock.offer(presentationTimeUs, releaseTimeNs)
915
+ },
916
+ )
917
+ if (surface != null) player.setVideoSurface(surface)
918
+ player.setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
919
+ player.prepare()
920
+ built.complete(player)
921
+ }
922
+ val player = built.get(5, java.util.concurrent.TimeUnit.SECONDS)
923
+ return Media3Player(player, 0, 0).also {
924
+ it.startAnchor()
925
+ it.attachErrorListener()
926
+ it.ownSurfaceAttachment()
927
+ // Held for read-only diagnostics: the trace has to be able to
928
+ // ask what volume the source player is actually at, and it
929
+ // samples from a thread that has no reference to it.
930
+ live = it
931
+ }
932
+ }
933
+ }
934
+ }