@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,374 @@
1
+ package dev.pictor.video
2
+
3
+ import org.json.JSONObject
4
+
5
+ /**
6
+ * Keeping the picture and the mix together through a stall.
7
+ *
8
+ * ## What goes wrong without it
9
+ *
10
+ * A clip seam makes the video player buffer while it switches decoder. The mix
11
+ * is one continuous file and has no reason to stop, so it runs ahead by exactly
12
+ * the length of the stall and keeps the lead — measured at +256 µs·10³ from the
13
+ * first seam onward, and it never came back.
14
+ *
15
+ * Correcting that afterwards does not work. A seek into a compressed mix costs
16
+ * 200–340 ms on a Pixel 7, which is the same size as the error, so a monitor
17
+ * aiming at where the picture *was* lands behind it and then chases the other
18
+ * way. Measured, it oscillated +264, −194, −207, −178 for as long as playback
19
+ * continued.
20
+ *
21
+ * ## What this does instead
22
+ *
23
+ * Both transports are held for the stall and released together, aligned.
24
+ *
25
+ * - **A stall holds the mix and remembers the intent.** The picture is already
26
+ * stopped — that is what a stall is — so only the mix needs holding. The
27
+ * user's intent to be playing is kept separately, because a hold is not a
28
+ * pause.
29
+ *
30
+ * Holding the picture's own transport was tried first and deadlocked: taking
31
+ * `playWhenReady` away stops its position advancing, and an advancing
32
+ * position is the only evidence that the stall has ended. One hold, zero
33
+ * releases, and playback never resumed.
34
+ * - **Resume waits for alignment.** When the picture is ready again, the mix's
35
+ * position is compared against it. If they already agree, both start. If they
36
+ * do not, the mix seeks *while everything is held* — nothing is advancing, so
37
+ * the seek has nothing to chase — and the release waits for it to land.
38
+ * - **Epochs guard it.** A hold belongs to a generation; a ready callback from
39
+ * a mix that has since been replaced cannot release a hold that is no longer
40
+ * its own.
41
+ * - **The user always wins.** An explicit pause, a background or an unmount
42
+ * clears the intent, and a pending automatic resume is then dropped rather
43
+ * than fired later at a surprising moment.
44
+ */
45
+ internal object PreviewSync {
46
+
47
+ /**
48
+ * How close counts as aligned when releasing a hold.
49
+ *
50
+ * Tighter than the drift backstop, because nothing is moving: the mix has
51
+ * been seeked while held and the only question is whether it landed. A
52
+ * value near the mix's own access-unit granularity is as good as this can
53
+ * be without decoding.
54
+ */
55
+ private const val ALIGN_US = 30_000L
56
+
57
+ /** How long to wait for a seek to land before giving up and releasing. */
58
+ private const val CONVERGE_TIMEOUT_NS = 1_500_000_000L
59
+
60
+ private enum class State { IDLE, HELD, AWAITING_SEEK }
61
+
62
+ /**
63
+ * How long the picture may stop advancing before it counts as stalled.
64
+ *
65
+ * Detected by measurement, not by a callback. `onPlaybackStateChanged`
66
+ * never reported `STATE_BUFFERING` at a clip seam on a Pixel 7 — the
67
+ * transition is seamless as far as the state machine is concerned — while
68
+ * the position stopped advancing for about 230 ms and the mix ran on. A
69
+ * hold keyed on that callback engaged zero times in a 22 s run.
70
+ *
71
+ * Two anchor publications is the floor: the pump runs at 50 ms, so one
72
+ * unchanged reading proves nothing.
73
+ */
74
+ private const val STALL_NS = 110_000_000L
75
+
76
+ @Volatile private var state = State.IDLE
77
+
78
+ /**
79
+ * Whether the person wants playback to be running.
80
+ *
81
+ * Separate from whether it *is* running. A hold stops both transports
82
+ * without changing this, which is what lets the release know it should
83
+ * start them again rather than leave them stopped.
84
+ */
85
+ @Volatile var intentPlaying = false
86
+ private set
87
+
88
+ /** The mix generation a hold belongs to. */
89
+ @Volatile private var holdEpoch = 0L
90
+
91
+ /** When the realigning seek was issued, for the convergence timeout. */
92
+ @Volatile private var seekAtNs = 0L
93
+
94
+ /** Where the seek aimed, so convergence can be judged against it. */
95
+ @Volatile private var seekTargetUs = 0L
96
+
97
+ /** The last *raw* anchor position, and when it last changed. */
98
+ @Volatile private var lastRawUs = Long.MIN_VALUE
99
+ @Volatile private var lastRawChangeNs = 0L
100
+
101
+ /** True while the picture is measurably not advancing. */
102
+ @Volatile var stalled = false
103
+ private set
104
+
105
+ @Volatile var stalls = 0L
106
+ private set
107
+
108
+ /** Counters, so a gate can assert that holds happened and resolved. */
109
+ @Volatile var holds = 0L
110
+ private set
111
+
112
+ @Volatile var releases = 0L
113
+ private set
114
+
115
+ @Volatile var realigningSeeks = 0L
116
+ private set
117
+
118
+ /** The picture's position, as *presented* rather than as decoded. */
119
+ fun presentedUs(): Long? {
120
+ val sample = FrameClock.latestSample() ?: return null
121
+ val ptsUs = sample[FrameClock.Field.PTS_US]
122
+ val window = sample[FrameClock.Field.WINDOW_INDEX].toInt()
123
+ return Media3Player.globalPositionUs(window, ptsUs)
124
+ }
125
+
126
+ /**
127
+ * The presented frame, with everything needed to say whether it is fresh.
128
+ *
129
+ * A presented time that stops changing has three possible causes and they
130
+ * need different fixes: the renderer drew the same frame again, the record
131
+ * is stale because nothing new was consumed, or the source timestamp is
132
+ * being mapped to the wrong global time. Reading the position alone cannot
133
+ * tell them apart — the frame's own identity can.
134
+ *
135
+ * `id` is the canonical sample's sequence, `generation` changes with the
136
+ * media item, `ptsUs` is the source timestamp and `consumedNs` is when the
137
+ * renderer latched it, on the same clock as everything else here.
138
+ */
139
+ fun presentedRecord(): LongArray? {
140
+ val sample = FrameClock.latestSample() ?: return null
141
+ val window = sample[FrameClock.Field.WINDOW_INDEX]
142
+ return longArrayOf(
143
+ sample[FrameClock.Field.ID],
144
+ sample[FrameClock.Field.GENERATION],
145
+ window,
146
+ sample[FrameClock.Field.PTS_US],
147
+ Media3Player.globalPositionUs(window.toInt(), sample[FrameClock.Field.PTS_US]),
148
+ sample[FrameClock.Field.CONSUMED_NS],
149
+ )
150
+ }
151
+
152
+ /** The picture's position as the decoder reports it, extrapolated. */
153
+ fun decodedUs(atNs: Long): Long {
154
+ val anchor = PlaybackAnchor.latest()
155
+ return Media3Player.globalPositionUs(anchor.windowIndex, PlaybackAnchor.positionAt(atNs))
156
+ }
157
+
158
+ private fun audioUs(atNs: Long): Long? {
159
+ val anchor = PreviewAudioPlayer.current()?.anchor ?: return null
160
+ return if (anchor.playing) anchor.positionUs + (atNs - anchor.monotonicNs) / 1000
161
+ else anchor.positionUs
162
+ }
163
+
164
+ private fun trace(type: String, extra: JSONObject.() -> Unit = {}) {
165
+ PreviewTrace.record(type) {
166
+ val atNs = System.nanoTime()
167
+ JSONObject()
168
+ .put("state", state.name)
169
+ .put("intent", intentPlaying)
170
+ .put("presentedUs", presentedUs() ?: -1)
171
+ .put("decodedUs", decodedUs(atNs))
172
+ .put("audioUs", audioUs(atNs) ?: -1)
173
+ .put("audioPlaying", PreviewAudioPlayer.current()?.anchor?.playing ?: false)
174
+ .also(extra)
175
+ }
176
+ }
177
+
178
+ /** The host is starting playback. */
179
+ fun onUserPlay() {
180
+ intentPlaying = true
181
+ state = State.IDLE
182
+ trace("sync.userPlay")
183
+ }
184
+
185
+ /**
186
+ * The host is stopping playback, or the screen is going away.
187
+ *
188
+ * Clears the intent, so a hold in flight releases into a stopped state
189
+ * rather than starting playback the person has already asked to stop.
190
+ */
191
+ fun onUserStop(reason: String) {
192
+ intentPlaying = false
193
+ state = State.IDLE
194
+ trace("sync.userStop") { put("reason", reason) }
195
+ }
196
+
197
+ /**
198
+ * The picture's buffering state changed.
199
+ *
200
+ * Kept because it is the earliest signal when it does fire, and it costs
201
+ * nothing when it does not. It is no longer the only one: see [STALL_NS].
202
+ */
203
+ fun onVideoBuffering(buffering: Boolean, holdVideo: (Boolean) -> Unit) {
204
+ if (!PreviewConfig.coordinator) return
205
+ if (buffering && intentPlaying) beginHold(holdVideo, "buffering")
206
+ }
207
+
208
+ private fun beginHold(holdVideo: (Boolean) -> Unit, why: String) {
209
+ if (state != State.IDLE) return
210
+ val mix = PreviewAudioPlayer.current() ?: return
211
+ state = State.HELD
212
+ holdEpoch = mix.epoch
213
+ holds += 1
214
+ attempts = 0
215
+ trace("sync.hold") { put("why", why) }
216
+ /*
217
+ The mix only.
218
+
219
+ The picture is already stopped — that is what a stall is — and taking
220
+ its transport away as well removed the very signal the release waits
221
+ for. `holdVideo` stays in the signature because an explicit stop still
222
+ needs to reach it.
223
+ */
224
+ mix.pause()
225
+ }
226
+
227
+ /** Realignment attempts within one hold, so a chase cannot run forever. */
228
+ @Volatile private var attempts = 0
229
+
230
+ /** At most this many seeks per hold. */
231
+ private const val MAX_ATTEMPTS = 2
232
+
233
+ /**
234
+ * Called from the player's own anchor pump, so it exists in production
235
+ * rather than only while a trace is armed.
236
+ */
237
+ fun tick(holdVideo: (Boolean) -> Unit) {
238
+ if (!PreviewConfig.coordinator) return
239
+ val nowNs = System.nanoTime()
240
+ val anchor = PlaybackAnchor.latest()
241
+
242
+ /*
243
+ Stall detection, from the raw position.
244
+
245
+ `positionAt` extrapolates, so it keeps advancing through a stall and
246
+ cannot see one. The published value is what actually stops moving.
247
+ */
248
+ if (anchor.positionUs != lastRawUs) {
249
+ lastRawUs = anchor.positionUs
250
+ lastRawChangeNs = nowNs
251
+ if (stalled) {
252
+ stalled = false
253
+ PreviewTrace.record("sync.stallEnd") {
254
+ JSONObject().put("positionUs", anchor.positionUs)
255
+ }
256
+ }
257
+ } else if (
258
+ intentPlaying && anchor.playing && !stalled &&
259
+ lastRawChangeNs != 0L && nowNs - lastRawChangeNs > STALL_NS
260
+ ) {
261
+ stalled = true
262
+ stalls += 1
263
+ PreviewTrace.record("sync.stallStart") {
264
+ JSONObject()
265
+ .put("positionUs", anchor.positionUs)
266
+ .put("stalledForMs", (nowNs - lastRawChangeNs) / 1_000_000)
267
+ }
268
+ beginHold(holdVideo, "stall")
269
+ }
270
+
271
+ if (state == State.IDLE) return
272
+ val mix = PreviewAudioPlayer.current() ?: run { state = State.IDLE; return }
273
+
274
+ if (!intentPlaying) {
275
+ // The person stopped while we were holding. Release the picture's
276
+ // transport but start nothing.
277
+ state = State.IDLE
278
+ holdVideo(false)
279
+ trace("sync.abandon")
280
+ return
281
+ }
282
+ if (mix.epoch != holdEpoch) {
283
+ // A new mix arrived mid-hold; the hold was about the old one.
284
+ state = State.IDLE
285
+ holdVideo(false)
286
+ trace("sync.staleHold")
287
+ return
288
+ }
289
+
290
+ // Still stalled, or the decoder says it is refilling: nothing to align
291
+ // against yet.
292
+ if (stalled || anchor.buffering) return
293
+
294
+ val atNs = nowNs
295
+ val videoUs = decodedUs(atNs)
296
+ val audio = audioUs(atNs) ?: return
297
+
298
+ when (state) {
299
+ State.HELD -> {
300
+ val gap = audio - videoUs
301
+ if (Math.abs(gap) <= ALIGN_US || attempts >= MAX_ATTEMPTS) {
302
+ release(mix, holdVideo, gap, if (attempts >= MAX_ATTEMPTS) "attemptsSpent" else "aligned")
303
+ } else {
304
+ /*
305
+ Seek while the mix is held.
306
+
307
+ It is paused, so the seek has nothing to chase on its own
308
+ side — the only thing still moving is the picture, and the
309
+ convergence check below re-reads it rather than assuming
310
+ the target is still current.
311
+ */
312
+ state = State.AWAITING_SEEK
313
+ seekAtNs = atNs
314
+ seekTargetUs = videoUs
315
+ attempts += 1
316
+ realigningSeeks += 1
317
+ trace("sync.realign") { put("gapUs", gap).put("toUs", videoUs).put("attempt", attempts) }
318
+ mix.seekTo(videoUs, mix.epoch)
319
+ }
320
+ }
321
+ State.AWAITING_SEEK -> {
322
+ val landed = Math.abs(audio - seekTargetUs) <= ALIGN_US
323
+ val timedOut = atNs - seekAtNs > CONVERGE_TIMEOUT_NS
324
+ if (timedOut) {
325
+ release(mix, holdVideo, audio - videoUs, "timeout")
326
+ } else if (landed) {
327
+ // Back to HELD rather than straight to release: the picture
328
+ // moved while the seek ran, so the gap is re-measured and
329
+ // may warrant one more attempt — bounded by MAX_ATTEMPTS.
330
+ state = State.HELD
331
+ }
332
+ }
333
+ State.IDLE -> Unit
334
+ }
335
+ }
336
+
337
+ private fun release(
338
+ mix: PreviewAudioPlayer,
339
+ holdVideo: (Boolean) -> Unit,
340
+ gapUs: Long,
341
+ why: String,
342
+ ) {
343
+ state = State.IDLE
344
+ releases += 1
345
+ attempts = 0
346
+ trace("sync.release") { put("gapUs", gapUs).put("why", why) }
347
+ // The picture was never held, so there is nothing to give back to it;
348
+ // the call is kept so an explicit stop can still reach the transport.
349
+ holdVideo(false)
350
+ mix.play(mix.epoch)
351
+ }
352
+
353
+ fun counters(): JSONObject =
354
+ JSONObject()
355
+ .put("holds", holds)
356
+ .put("releases", releases)
357
+ .put("realigningSeeks", realigningSeeks)
358
+ .put("stalls", stalls)
359
+ .put("state", state.name)
360
+ .put("intentPlaying", intentPlaying)
361
+
362
+ fun reset() {
363
+ state = State.IDLE
364
+ intentPlaying = false
365
+ holds = 0
366
+ releases = 0
367
+ realigningSeeks = 0
368
+ attempts = 0
369
+ stalls = 0
370
+ stalled = false
371
+ lastRawUs = Long.MIN_VALUE
372
+ lastRawChangeNs = 0
373
+ }
374
+ }