@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,591 @@
1
+ package dev.pictor.video
2
+
3
+ /**
4
+ * One canonical sample per displayed frame — the only source of timing truth.
5
+ *
6
+ * Everything reported about synchronisation comes from here: the percentiles
7
+ * and the on-screen readout read the *same* sample, matched by [Sample.ID]. An
8
+ * independent readout calculation was how a 22 ms disagreement went unnoticed
9
+ * between two views of one quantity.
10
+ *
11
+ * ## What is paired with what
12
+ *
13
+ * `VideoFrameMetadataListener` fires just before a frame is released to the
14
+ * surface; `onFrameAvailable` follows once it is queued, and **always after**
15
+ * the metadata for that frame. `updateTexImage()` then latches the *newest*
16
+ * buffer, discarding anything behind it.
17
+ *
18
+ * **The pairing happens when availability fires, not when the renderer ticks.**
19
+ * `onFrameAvailable` coalesces: several queued frames can raise one callback.
20
+ * Draining the two queues independently — one metadata per availability —
21
+ * therefore leaves metadata behind whenever that happens, and the surplus never
22
+ * clears. The reported PTS falls further behind with every coalesced callback,
23
+ * which measured as a *growing* offset: 21 ms after twenty-five seconds and
24
+ * 616 ms after five minutes, while the picture stayed correct.
25
+ *
26
+ * So availability claims **all** metadata queued at that moment and keeps the
27
+ * newest as the pending pair. That is sound precisely because metadata always
28
+ * precedes its own availability: everything queued when the callback fires
29
+ * belongs to a frame already handed to the surface. The renderer then consumes
30
+ * one complete pair, and metadata cannot accumulate.
31
+ *
32
+ * ## Time bases
33
+ *
34
+ * Every nanosecond value here is `System.nanoTime`. `releaseTimeNs` from Media3
35
+ * is documented on that clock, `onFrameAvailable` is stamped with it, and the
36
+ * anchor is published with it, so differences between them are meaningful. The
37
+ * only microsecond values are media times. `TIME_UNSET` never reaches this
38
+ * class; the player maps it to zero before publishing.
39
+ *
40
+ * **Every method is `@JvmStatic`.** A Kotlin `object`'s members are instance
41
+ * methods on `INSTANCE`, so `GetStaticMethodID` returns null without it and JNI
42
+ * aborts the process with `JNI DETECTED ERROR IN APPLICATION: mid == null` — no
43
+ * Java stack, and a message naming the JNI call rather than the annotation.
44
+ */
45
+ internal object FrameClock {
46
+
47
+ /** A few frames of pipeline depth, and no more. */
48
+ const val CAPACITY = 32
49
+
50
+ /** Fields of one canonical sample, in the order [take] returns them. */
51
+ const val FIELDS = 18
52
+
53
+ /** Indices into the returned array. Mirrored in `pictor_videosurface.cpp`. */
54
+ object Field {
55
+ const val ID = 0
56
+ const val GENERATION = 1
57
+ const val PTS_US = 2
58
+ const val METADATA_NS = 3
59
+ const val RELEASE_NS = 4
60
+ const val AVAILABLE_NS = 5
61
+ const val CONSUMED_NS = 6
62
+ const val ANCHOR_POSITION_US = 7
63
+ const val ANCHOR_NS = 8
64
+ const val RATE_MILLI = 9
65
+ const val POSITION_AT_RELEASE_US = 10
66
+ const val POSITION_AT_CONSUMPTION_US = 11
67
+ const val FLAGS = 12
68
+ const val ANCHOR_AGE_NS = 13
69
+ const val COALESCED = 14
70
+ /**
71
+ * Which playlist window the frame came from.
72
+ *
73
+ * Carried per frame rather than read from player state, because state
74
+ * is published on a timer and a seam is exactly where a frame and a
75
+ * timer-sampled window index disagree. A consumer uses this to refuse a
76
+ * frame belonging to a clip the playhead has already left.
77
+ */
78
+ const val WINDOW_INDEX = 15
79
+
80
+ /**
81
+ * The acquired buffer's own timestamp, after `updateTexImage`.
82
+ *
83
+ * Not media time. Measured, this is a `System.nanoTime`-based value in
84
+ * the same domain as `RELEASE_NS`, so it identifies *which buffer* was
85
+ * acquired rather than which frame it depicts.
86
+ *
87
+ * The metadata deque is coalesced to its newest entry on every
88
+ * availability callback, so the pairing between "the sample we kept"
89
+ * and "the buffer `updateTexImage` took" is an assumption. This is the
90
+ * one fact that can check it: `SurfaceTexture.getTimestamp()` is the
91
+ * acquired buffer's own presentation time. A disagreement means the
92
+ * frame on screen is not the frame the record describes, which is the
93
+ * difference between a late frame and a mislabelled one.
94
+ */
95
+ const val TEXTURE_PTS_US = 16
96
+
97
+ /**
98
+ * Whether this sample's pixels and its timestamps are the same frame.
99
+ *
100
+ * 1 when the latched buffer's own timestamp matched a metadata entry
101
+ * inside the pairing window, and matched exactly one. 0 otherwise: no
102
+ * timestamp from the producer, no candidate close enough, or two
103
+ * candidates equally close.
104
+ *
105
+ * A 0 is not a small error. It means the pair is a guess — the claimed
106
+ * metadata with whatever buffer happened to be latched — which is the
107
+ * shape that put frame 7's pixels under frame 2's presentation time.
108
+ * Nothing may gate readiness or judge frame identity on one.
109
+ */
110
+ const val ASSOCIATION = 17
111
+ }
112
+
113
+ private val lock = Any()
114
+ private val metadata = ArrayDeque<LongArray>()
115
+
116
+ /**
117
+ * The newest complete pair, waiting for the renderer.
118
+ *
119
+ * One slot, not a queue: `updateTexImage` shows the newest buffer, so an
120
+ * older pending pair describes a frame nobody will see. A newer pair
121
+ * replaces it and the replaced one counts as [coalesced].
122
+ */
123
+ private var pending: LongArray? = null
124
+
125
+ @Volatile
126
+ var generation: Int = 0
127
+ private set
128
+
129
+ @Volatile
130
+ private var nextId: Long = 1
131
+
132
+ /** The sample the renderer most recently consumed. Readout reads this. */
133
+ @Volatile
134
+ private var latest: LongArray? = null
135
+
136
+ @Volatile var overflowed: Long = 0; private set
137
+ @Volatile var unpaired: Long = 0; private set
138
+ @Volatile var stale: Long = 0; private set
139
+ @Volatile var coalesced: Long = 0; private set
140
+ @Volatile var highWater: Int = 0; private set
141
+
142
+ /** Everything queued belongs to the clip that has just been replaced. */
143
+ /**
144
+ * Whether the transport is in continuous playback.
145
+ *
146
+ * Set by whoever owns the transport, not inferred. A gap between frame
147
+ * callbacks is not a transition: a clip seam produces one and playback is
148
+ * continuous across it, so pacing must stay engaged. What ends a stream is
149
+ * a pause, a seek or a start — and only the transport knows which of those
150
+ * just happened.
151
+ */
152
+ @Volatile private var streaming = false
153
+
154
+ /**
155
+ * Whether the latch enforces the intended presentation time.
156
+ *
157
+ * Off, and measured rather than assumed: enforcing it at the latch costs a
158
+ * frame per deferral. `markAvailable` coalesces the metadata deque whenever
159
+ * something is already pending, so a frame held for its due time has its
160
+ * record discarded by the next arrival and `updateTexImage` takes the newer
161
+ * buffer regardless. The displayed cadence in a 1x clip went from 33.3 ms
162
+ * to 66.7 ms — half the frame rate — to remove a 34 ms systematic lead.
163
+ *
164
+ * That is the wrong trade, so the enforcement stays available and stays
165
+ * off. Honouring the schedule without dropping frames needs the previous
166
+ * picture retained, and the external texture cannot do that: one
167
+ * `updateTexImage` overwrites it, and a recorded `SkPicture` samples the
168
+ * texture at draw time rather than holding its pixels. Retaining it means
169
+ * an owned copy — one 1080x1920 RGBA texture, about 8.3 MB, plus a
170
+ * full-frame blit per held frame.
171
+ */
172
+ @Volatile var enforcePresentationSchedule = false
173
+
174
+ @JvmStatic
175
+ fun setStreaming(on: Boolean) {
176
+ streaming = on
177
+ }
178
+
179
+ @JvmStatic
180
+ fun isStreaming(): Boolean = streaming
181
+
182
+ @JvmStatic
183
+ fun bump() {
184
+ // A discontinuity ends the stream: the frames in flight belong to the
185
+ // position being left, and their release times describe it.
186
+ streaming = false
187
+ synchronized(lock) {
188
+ generation += 1
189
+ stale += metadata.size + (if (pending == null) 0 else 1)
190
+ metadata.clear()
191
+ pending = null
192
+ latest = null
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Which playlist window is playing, as the player last reported it.
198
+ *
199
+ * Lives here rather than being passed in, because the only place frames are
200
+ * offered is a factory that builds the player before the instance holding
201
+ * that number exists. Set on the player's own thread at a media-item
202
+ * transition — which is the instant it changes — and read on the playback
203
+ * thread when a frame is offered.
204
+ */
205
+ @Volatile
206
+ @JvmStatic
207
+ var windowIndex: Int = 0
208
+
209
+ /** Metadata callbacks so far. With [available], separates the two halves. */
210
+ @Volatile var offered: Long = 0; private set
211
+
212
+ /** From `VideoFrameMetadataListener`, before the frame is released. */
213
+ @JvmStatic
214
+ fun offer(ptsUs: Long, scheduledReleaseNs: Long) {
215
+ val now = PlaybackAnchor.nowNs()
216
+ /*
217
+ How far ahead the renderer scheduled this frame.
218
+
219
+ `releaseOutputBuffer(index, releaseTimeNs)` hands the buffer to the
220
+ surface's frame scheduler with a desired present time. A lead of a
221
+ frame or two is normal; a lead measured in seconds means the frame was
222
+ handed over but is not going to be presented for a very long time, and
223
+ from outside that is indistinguishable from a producer that stopped.
224
+ */
225
+ lastReleaseLeadNs = scheduledReleaseNs - now
226
+ lastOfferedPtsUs = ptsUs
227
+ if (lastReleaseLeadNs > 1_000_000_000L) farFutureOffers += 1
228
+ if (lastReleaseLeadNs < -1_000_000_000L) farPastOffers += 1
229
+ val anchor = PlaybackAnchor.latest()
230
+ offered += 1
231
+ val sample = LongArray(FIELDS)
232
+ sample[Field.GENERATION] = generation.toLong()
233
+ sample[Field.PTS_US] = ptsUs
234
+ sample[Field.METADATA_NS] = now
235
+ sample[Field.RELEASE_NS] = scheduledReleaseNs
236
+ sample[Field.ANCHOR_POSITION_US] = anchor.positionUs
237
+ sample[Field.ANCHOR_NS] = anchor.monotonicNs
238
+ sample[Field.RATE_MILLI] = (anchor.rate * 1000).toLong()
239
+ sample[Field.POSITION_AT_RELEASE_US] = PlaybackAnchor.positionAt(scheduledReleaseNs)
240
+ sample[Field.FLAGS] = stateBits(anchor)
241
+ sample[Field.ANCHOR_AGE_NS] = PlaybackAnchor.ageNs(scheduledReleaseNs)
242
+ sample[Field.WINDOW_INDEX] = windowIndex.toLong()
243
+ synchronized(lock) {
244
+ if (metadata.size >= CAPACITY) {
245
+ metadata.removeFirst()
246
+ overflowed += 1
247
+ }
248
+ metadata.addLast(sample)
249
+ if (metadata.size > highWater) highWater = metadata.size
250
+ }
251
+ }
252
+
253
+ /**
254
+ * From `SurfaceTexture.OnFrameAvailableListener`, once queued.
255
+ *
256
+ * Claims every metadata event queued at this instant and keeps the newest.
257
+ * Anything older describes a buffer the next `updateTexImage` will skip.
258
+ */
259
+ /** Every availability callback, paired or not. A liveness counter. */
260
+ @Volatile var available: Long = 0; private set
261
+
262
+ @JvmStatic
263
+ fun markAvailable() {
264
+ val now = PlaybackAnchor.nowNs()
265
+ available += 1
266
+ synchronized(lock) {
267
+ if (metadata.isEmpty()) {
268
+ // Availability with nothing to claim. Not fatal — a repeat
269
+ // callback, or a frame whose metadata was discarded by a
270
+ // generation change — but worth counting.
271
+ orphaned += 1
272
+ return
273
+ }
274
+ var dropped = 0
275
+ while (metadata.size > 1) {
276
+ metadata.removeFirst()
277
+ dropped += 1
278
+ }
279
+ if (pending != null) dropped += 1
280
+ coalesced += dropped.toLong()
281
+ val sample = metadata.removeFirst()
282
+ sample[Field.AVAILABLE_NS] = now
283
+ sample[Field.COALESCED] = dropped.toLong()
284
+ pending = sample
285
+ }
286
+ }
287
+
288
+ /** Availability callbacks that found no metadata to claim. */
289
+ @Volatile var orphaned: Long = 0; private set
290
+
291
+ @JvmStatic
292
+ fun pendingFrames(): Int = synchronized(lock) { if (pending == null) 0 else 1 }
293
+
294
+ private fun stateBits(anchor: PlaybackAnchor.Sample): Long {
295
+ var bits = 0L
296
+ if (anchor.playing) bits = bits or 1L
297
+ if (anchor.buffering) bits = bits or 2L
298
+ if (anchor.discontinuity) bits = bits or 4L
299
+ return bits
300
+ }
301
+
302
+ /**
303
+ * Complete the sample for the frame the renderer is about to show.
304
+ *
305
+ * Drains to the newest pending frame, because that is the one
306
+ * `updateTexImage` will produce. Returns null when nothing of the current
307
+ * generation is pending — the honest answer after a seek, and better than
308
+ * a stale pairing.
309
+ */
310
+ /**
311
+ * Every entry to [take], counted before the lock is attempted.
312
+ *
313
+ * The one measurement that separates a consumer which has stopped ticking
314
+ * from one blocked inside the latch. `taken` and `unpaired` are both
315
+ * recorded *after* the lock is held, so a consumer waiting on it looks
316
+ * exactly like a consumer that never arrived — and a frozen picture with
317
+ * `unpaired` at zero could mean either.
318
+ */
319
+ @Volatile var takeCalls: Long = 0; private set
320
+
321
+ @JvmStatic
322
+ /** Acquisitions whose texture timestamp did not match the paired metadata. */
323
+ @Volatile var mismatched: Long = 0; private set
324
+
325
+ /** Acquisitions where the texture reported no usable timestamp. */
326
+ @Volatile var unverified: Long = 0; private set
327
+
328
+ /**
329
+ * How far the texture's timestamp may sit from the paired metadata.
330
+ *
331
+ * Half a frame at 30 fps. The two describe the same buffer, so they should
332
+ * agree exactly; the tolerance is for the rounding between microseconds and
333
+ * nanoseconds and for producers that offset their timestamps.
334
+ */
335
+ private const val IDENTITY_TOLERANCE_US = 16_000L
336
+
337
+ @JvmStatic
338
+ fun take(consumedNs: Long): LongArray? = take(consumedNs, 0L)
339
+
340
+ /**
341
+ * How far a buffer's timestamp may sit from a metadata entry's release time
342
+ * and still be the same buffer.
343
+ *
344
+ * Half a frame at 30 fps. Both are `System.nanoTime` values describing the
345
+ * same handover, so they agree closely; the tolerance covers the scheduler's
346
+ * own rounding.
347
+ */
348
+ private const val PAIRING_TOLERANCE_NS = 16_000_000L
349
+
350
+ /**
351
+ * How much closer the best candidate must be than the next.
352
+ *
353
+ * Below this the two are indistinguishable and neither can be claimed. A
354
+ * quarter of the pairing window: wide enough that ordinary jitter still
355
+ * resolves, narrow enough that two frames a whole interval apart do.
356
+ */
357
+ private const val AMBIGUITY_MARGIN_NS = 4_000_000L
358
+
359
+ /** Latches whose pixels could not be tied to any metadata entry. */
360
+ @Volatile var unassociated: Long = 0; private set
361
+
362
+ /** Latches where the buffer was not the one the claimed metadata described. */
363
+ @Volatile var repaired: Long = 0; private set
364
+
365
+ @JvmStatic
366
+ fun take(consumedNs: Long, texturePtsNs: Long): LongArray? {
367
+ takeCalls += 1
368
+ synchronized(lock) {
369
+ /*
370
+ Pair with the buffer that was actually latched, not the one that
371
+ was claimed when availability first fired.
372
+
373
+ `markAvailable` claims the newest metadata *at that instant* and
374
+ `updateTexImage` takes the newest buffer *at its instant*, and
375
+ between the two more frames can arrive. Measured at startup: the
376
+ renderer released frames 2 to 7 in a burst, availability claimed
377
+ frame 2, the latch took frame 7's buffer, and the pair went out
378
+ as frame 7's pixels labelled 33.3 ms — a wrong picture that every
379
+ timestamp in the system agreed was right.
380
+
381
+ The texture's own timestamp settles it. It is a `System.nanoTime`
382
+ value on this device, the same domain as `RELEASE_NS`, so the
383
+ entry describing the latched buffer is the one whose release time
384
+ it matches. Without a usable timestamp this falls back to the
385
+ claimed pair, which is the old behaviour.
386
+ */
387
+ var sample = pending
388
+ var associated = false
389
+ if (texturePtsNs > 0L) {
390
+ var best: LongArray? = null
391
+ var bestSkew = Long.MAX_VALUE
392
+ var runnerUp = Long.MAX_VALUE
393
+ val claimed = pending
394
+ if (claimed != null) {
395
+ bestSkew = Math.abs(claimed[Field.RELEASE_NS] - texturePtsNs)
396
+ best = claimed
397
+ }
398
+ for (entry in metadata) {
399
+ val skew = Math.abs(entry[Field.RELEASE_NS] - texturePtsNs)
400
+ if (skew < bestSkew) {
401
+ runnerUp = bestSkew
402
+ bestSkew = skew
403
+ best = entry
404
+ } else if (skew < runnerUp) {
405
+ runnerUp = skew
406
+ }
407
+ }
408
+ /*
409
+ Unique, or it is not an association.
410
+
411
+ Two entries equally close to the latched timestamp cannot both
412
+ describe it, and picking the nearer by a nanosecond is a coin
413
+ toss dressed as a measurement. Ambiguity is reported as
414
+ unverified, like absence.
415
+ */
416
+ if (best != null && bestSkew <= PAIRING_TOLERANCE_NS && runnerUp - bestSkew >= AMBIGUITY_MARGIN_NS) {
417
+ associated = true
418
+ }
419
+ if (associated && best != null && best !== pending) {
420
+ /*
421
+ The match is ahead of the claimed pair. Everything between
422
+ describes a buffer this latch skipped, so it is dropped and
423
+ counted rather than shown later out of order.
424
+ */
425
+ var dropped = if (pending != null) 1 else 0
426
+ while (metadata.isNotEmpty() && metadata.first() !== best) {
427
+ metadata.removeFirst()
428
+ dropped += 1
429
+ }
430
+ if (metadata.isNotEmpty() && metadata.first() === best) metadata.removeFirst()
431
+ coalesced += dropped.toLong()
432
+ best!![Field.AVAILABLE_NS] = consumedNs
433
+ best[Field.COALESCED] = dropped.toLong()
434
+ pending = best
435
+ sample = best
436
+ repaired += 1
437
+ }
438
+ }
439
+ if (sample == null) {
440
+ unpaired += 1
441
+ return null
442
+ }
443
+ if (sample[Field.GENERATION] != generation.toLong()) {
444
+ // The clip moved on between pairing and consumption.
445
+ pending = null
446
+ stale += 1
447
+ unpaired += 1
448
+ return null
449
+ }
450
+ pending = null
451
+ taken += 1
452
+ /*
453
+ Stamped on every sample, including the ones that fell back.
454
+
455
+ The fallback keeps the claimed pair so a producer without
456
+ timestamps still gets a picture, but it is a guess and has to
457
+ travel as one: readiness will not release on it and the identity
458
+ gates will not count it.
459
+ */
460
+ sample[Field.ASSOCIATION] = if (associated) 1L else 0L
461
+ if (!associated) unassociated += 1
462
+ sample[Field.ID] = nextId++
463
+ sample[Field.CONSUMED_NS] = consumedNs
464
+ sample[Field.POSITION_AT_CONSUMPTION_US] = PlaybackAnchor.positionAt(consumedNs)
465
+ /*
466
+ The acquired texture, labelled with what it actually holds.
467
+
468
+ Recorded rather than assumed, and the disagreement is counted
469
+ rather than smoothed: a sample carrying another frame's timestamp
470
+ would make a correctly-delivered frame look late, and there is no
471
+ way to tell those apart afterwards from the numbers alone.
472
+ */
473
+ if (texturePtsNs > 0L) {
474
+ sample[Field.TEXTURE_PTS_US] = texturePtsNs / 1000
475
+ /*
476
+ Compared against the release time, not the media timestamp.
477
+
478
+ Measured on a Pixel 7, `SurfaceTexture.getTimestamp` returns a
479
+ `System.nanoTime`-based value here — 175,110,822,173 us, about
480
+ forty-nine hours of uptime — and not the buffer's presentation
481
+ time. Comparing it to `PTS_US` counted every single frame as
482
+ mismatched, which is a broken check reporting a broken
483
+ pipeline. Against `RELEASE_NS` the two share a domain, and the
484
+ question it answers is the useful one: is the buffer that was
485
+ acquired the one whose release we paired?
486
+ */
487
+ val skewUs = Math.abs(texturePtsNs - sample[Field.RELEASE_NS]) / 1000
488
+ if (skewUs > IDENTITY_TOLERANCE_US) mismatched += 1
489
+ } else {
490
+ sample[Field.TEXTURE_PTS_US] = -1
491
+ unverified += 1
492
+ }
493
+ latest = sample
494
+ return sample
495
+ }
496
+ }
497
+
498
+ /** Samples actually consumed by the renderer. A liveness counter. */
499
+ @Volatile var lastReleaseLeadNs: Long = 0; private set
500
+ @Volatile var lastOfferedPtsUs: Long = 0; private set
501
+ @Volatile var farFutureOffers: Long = 0; private set
502
+ @Volatile var farPastOffers: Long = 0; private set
503
+ @Volatile var taken: Long = 0; private set
504
+
505
+ /** The most recent canonical sample, for a readout. Never recomputed. */
506
+ @JvmStatic
507
+ fun latestSample(): LongArray? = latest
508
+
509
+ /**
510
+ * Everything needed to say *which stage* stopped, in one read.
511
+ *
512
+ * `latest` freezing can mean the picture froze, the renderer stopped
513
+ * consuming, or metadata stopped arriving — and an offset summary cannot
514
+ * tell those apart. These are the counters that can.
515
+ *
516
+ * **Volatiles only, and deliberately no `depth` or `pendingFrames`.** Those
517
+ * take [lock], which `offer`, `markAvailable` and `take` all hold on the
518
+ * paths that decode and draw. A sampler reading them at 40 Hz contended
519
+ * with the renderer badly enough to stop it consuming frames entirely —
520
+ * the instrument froze the picture it was measuring. Anything here must be
521
+ * readable without waiting for a frame.
522
+ */
523
+ /**
524
+ * When the pending frame was asked to be presented, or 0 if none.
525
+ *
526
+ * Exposed so the consumer can honour that time instead of latching the
527
+ * instant a buffer is queued. Measured, it latched about 34 ms early:
528
+ * `releaseOutputBuffer(index, releaseTimeNs)` queues immediately with a
529
+ * desired present time in the future, and nothing downstream was enforcing
530
+ * it — so the picture ran ahead of the sound by that much and the
531
+ * comparison against the intended time described an intention nobody kept.
532
+ */
533
+ /**
534
+ * When the pending frame was asked to be presented, or 0 if none.
535
+ *
536
+ * Zero while the transport is not streaming, which is the whole of the
537
+ * first-frame readiness path: a start or a seek needs its first frame
538
+ * latched as soon as it exists, and there is nothing yet for it to be early
539
+ * relative to. Pacing resumes when playback does.
540
+ */
541
+ @JvmStatic
542
+ fun pendingDueNs(): Long =
543
+ if (!streaming || !enforcePresentationSchedule) 0L
544
+ else synchronized(lock) { pending?.get(Field.RELEASE_NS) ?: 0L }
545
+
546
+ @JvmStatic
547
+ fun pipeline(): LongArray = longArrayOf(
548
+ offered, available, taken, orphaned, unpaired, stale, coalesced,
549
+ generation.toLong(), takeCalls,
550
+ )
551
+
552
+ @JvmStatic
553
+ fun depth(): Int = synchronized(lock) { metadata.size }
554
+
555
+ /** overflowed, unpaired, stale, highWater, generation, coalesced, orphaned. */
556
+ @JvmStatic
557
+ fun counters(): LongArray = longArrayOf(
558
+ overflowed, unpaired, stale, highWater.toLong(), generation.toLong(), coalesced,
559
+ orphaned,
560
+ )
561
+
562
+ @JvmStatic
563
+ fun reset() {
564
+ synchronized(lock) {
565
+ generation = 0
566
+ nextId = 1
567
+ overflowed = 0
568
+ available = 0
569
+ offered = 0
570
+ unpaired = 0
571
+ stale = 0
572
+ coalesced = 0
573
+ orphaned = 0
574
+ // Identity counters too: a static tally that survives a reset makes
575
+ // every test after the first read another test's evidence.
576
+ mismatched = 0
577
+ repaired = 0
578
+ unassociated = 0
579
+ unverified = 0
580
+ taken = 0
581
+ takeCalls = 0
582
+ streaming = false
583
+ enforcePresentationSchedule = false
584
+ highWater = 0
585
+ metadata.clear()
586
+ pending = null
587
+ latest = null
588
+ }
589
+ }
590
+
591
+ }