@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,26 @@
1
+ package dev.pictor.video
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ /** Autolinked entry point. Android only; iOS is deferred by design. */
10
+ class PictorVideoPackage : BaseReactPackage() {
11
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
12
+ if (name == PictorVideoModule.NAME) PictorVideoModule(reactContext) else null
13
+
14
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
15
+ mapOf(
16
+ PictorVideoModule.NAME to ReactModuleInfo(
17
+ PictorVideoModule.NAME,
18
+ PictorVideoModule.NAME,
19
+ false, // canOverrideExistingModule
20
+ false, // needsEagerInit
21
+ false, // isCxxModule
22
+ true, // isTurboModule
23
+ ),
24
+ )
25
+ }
26
+ }
@@ -0,0 +1,161 @@
1
+ package dev.pictor.video
2
+
3
+ /**
4
+ * The player's position, sampled on its own thread and extrapolated elsewhere.
5
+ *
6
+ * `ExoPlayer` may only be read from the application thread, and measuring
7
+ * synchronisation with a `post` and a latch puts a thread hop *inside the
8
+ * measurement*: the position arrives from later than the frame it is compared
9
+ * against. So the position is published as an anchor — `(positionUs,
10
+ * monotonicNs, rate)` — and readers extrapolate.
11
+ *
12
+ * **The anchor is only as good as its granularity.** `Player.currentPosition`
13
+ * does not advance continuously; it is itself derived, and between its updates
14
+ * it can return a value already behind real time. Extrapolating forward from a
15
+ * stale value yields a position that is too low, which shows up as a constant
16
+ * positive bias in any offset computed against it. [granularity] measures that
17
+ * directly rather than leaving it as a suspicion — how often the value changes,
18
+ * how big its steps are, and how old the anchor was when it was used.
19
+ *
20
+ * Lock-free: one immutable [Sample] behind a volatile reference. Readers on the
21
+ * render path never block and never see a half-written value.
22
+ */
23
+ internal object PlaybackAnchor {
24
+
25
+ /** `System.nanoTime`, or a test's clock. Read through [nowNs]. */
26
+ @Volatile
27
+ var clock: () -> Long = { System.nanoTime() }
28
+
29
+ fun nowNs(): Long = clock()
30
+
31
+ /**
32
+ * An instant of player state. Immutable; replaced, never mutated.
33
+ *
34
+ * @param positionUs media position at [monotonicNs]
35
+ * @param monotonicNs when the position was read, on the same clock as
36
+ * `VideoFrameMetadataListener`'s `releaseTimeNs`
37
+ * @param rate playback speed, so extrapolation tracks 0.5× and 2×
38
+ * @param playing whether the clock is advancing at all
39
+ * @param buffering a stall, during which extrapolation is meaningless
40
+ * @param discontinuity a seek or source change has invalidated what came
41
+ * before
42
+ */
43
+ data class Sample(
44
+ val positionUs: Long,
45
+ val monotonicNs: Long,
46
+ val rate: Float,
47
+ val playing: Boolean,
48
+ val buffering: Boolean,
49
+ val discontinuity: Boolean,
50
+ val generation: Int,
51
+ /**
52
+ * The playlist window [positionUs] is measured within.
53
+ *
54
+ * Carried because the position is window-local: a reader converting to
55
+ * project time needs to know which window it belongs to, and reading
56
+ * that separately would pair a position with an index from a different
57
+ * instant.
58
+ */
59
+ val windowIndex: Int = 0,
60
+ )
61
+
62
+ /** What the anchor's own resolution is, measured rather than assumed. */
63
+ data class Granularity(
64
+ /** Anchor publications so far. */
65
+ val publications: Long,
66
+ /** Publications where the position actually moved. */
67
+ val changes: Long,
68
+ /** Largest observed jump between consecutive distinct positions, µs. */
69
+ val maxStepUs: Long,
70
+ /** Sum of steps, for a mean. */
71
+ val totalStepUs: Long,
72
+ /** Longest the position stayed unchanged, in nanoseconds. */
73
+ val maxStaleNs: Long,
74
+ )
75
+
76
+ private val EMPTY = Sample(0, 0, 1f, false, false, false, 0, 0)
77
+
78
+ @Volatile
79
+ private var current = EMPTY
80
+
81
+ @Volatile
82
+ private var publications = 0L
83
+
84
+ @Volatile
85
+ private var changes = 0L
86
+
87
+ @Volatile
88
+ private var maxStepUs = 0L
89
+
90
+ @Volatile
91
+ private var totalStepUs = 0L
92
+
93
+ @Volatile
94
+ private var maxStaleNs = 0L
95
+
96
+ @Volatile
97
+ private var lastChangeNs = 0L
98
+
99
+ @JvmStatic
100
+ fun publish(sample: Sample) {
101
+ val previous = current
102
+ publications += 1
103
+ if (sample.positionUs != previous.positionUs) {
104
+ changes += 1
105
+ val step = kotlin.math.abs(sample.positionUs - previous.positionUs)
106
+ if (previous.monotonicNs != 0L) {
107
+ totalStepUs += step
108
+ if (step > maxStepUs) maxStepUs = step
109
+ val stale = sample.monotonicNs - lastChangeNs
110
+ if (lastChangeNs != 0L && stale > maxStaleNs) maxStaleNs = stale
111
+ }
112
+ lastChangeNs = sample.monotonicNs
113
+ }
114
+ current = sample
115
+ }
116
+
117
+ @JvmStatic
118
+ fun latest(): Sample = current
119
+
120
+ @JvmStatic
121
+ fun granularity(): Granularity =
122
+ Granularity(publications, changes, maxStepUs, totalStepUs, maxStaleNs)
123
+
124
+ /** How old the anchor was at [atNs], in nanoseconds. */
125
+ @JvmStatic
126
+ fun ageNs(atNs: Long): Long {
127
+ val anchor = current
128
+ if (anchor.monotonicNs == 0L) return 0
129
+ return atNs - anchor.monotonicNs
130
+ }
131
+
132
+ /**
133
+ * Where the playhead is at [atMonotonicNs], from the newest anchor.
134
+ *
135
+ * Returns the anchor unextrapolated when paused or buffering: the clock is
136
+ * not advancing then, and pretending otherwise would invent a position.
137
+ * Extrapolating backwards is allowed — a frame's release time can precede
138
+ * the newest anchor — because refusing would bias the result toward late.
139
+ */
140
+ @JvmStatic
141
+ fun positionAt(atMonotonicNs: Long): Long {
142
+ val anchor = current
143
+ if (!anchor.playing || anchor.buffering || anchor.monotonicNs == 0L) {
144
+ return anchor.positionUs
145
+ }
146
+ val elapsedNs = atMonotonicNs - anchor.monotonicNs
147
+ return anchor.positionUs + (elapsedNs / 1000.0 * anchor.rate).toLong()
148
+ }
149
+
150
+ @JvmStatic
151
+ fun reset() {
152
+ current = EMPTY
153
+ publications = 0
154
+ changes = 0
155
+ maxStepUs = 0
156
+ totalStepUs = 0
157
+ maxStaleNs = 0
158
+ lastChangeNs = 0
159
+ clock = { System.nanoTime() }
160
+ }
161
+ }
@@ -0,0 +1,291 @@
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.MediaItem
8
+ import androidx.media3.common.PlaybackException
9
+ import androidx.media3.common.Player
10
+ import androidx.media3.exoplayer.ExoPlayer
11
+ import org.json.JSONObject
12
+
13
+ /**
14
+ * Plays one prepared mix, and nothing else.
15
+ *
16
+ * ## Why a second player rather than a second track on the first
17
+ *
18
+ * The video player renders into a persistent external-OES surface and carries a
19
+ * playlist of clips, each with its own speed. The mix is a single continuous
20
+ * file covering the whole timeline, already re-timed — its position *is* the
21
+ * global timeline position, with no window index and no per-item rate. Feeding
22
+ * it to the video player would mean a playlist whose items disagree about what
23
+ * their positions mean.
24
+ *
25
+ * So this is deliberately small: one item, no surface, no playlist.
26
+ *
27
+ * ## Timeline position, not player position
28
+ *
29
+ * A caller asks for a global timeline microsecond and gets one back. That is
30
+ * the only coordinate the two players share, and it is what makes drift
31
+ * measurable — [state] reports the mix's own position alongside the wall clock
32
+ * it was sampled at, so a monitor compares two timestamps rather than
33
+ * accumulating deltas that drift by construction.
34
+ *
35
+ * ## Nothing here interprets the document
36
+ *
37
+ * No gain, no fades, no speed. All of that was compiled into the file by
38
+ * [AudioMixer] from the same program an export uses. This player's whole job is
39
+ * to be at the right position.
40
+ */
41
+ internal class PreviewAudioPlayer private constructor(private val player: ExoPlayer) {
42
+
43
+ @Volatile private var released = false
44
+
45
+ /** Everything ExoPlayer is touched from is its own application thread. */
46
+ private val main = Handler(Looper.getMainLooper())
47
+
48
+ /**
49
+ * The mix's position, published rather than fetched.
50
+ *
51
+ * `ExoPlayer` may only be read from the application thread, so a caller
52
+ * asking from anywhere else pays a thread hop *inside the measurement* —
53
+ * the position arrives from later than the moment it is compared against.
54
+ * The video player already solves this by publishing an anchor; the mix
55
+ * publishes one on the same clock so the two can simply be subtracted.
56
+ */
57
+ @Volatile var anchor: Anchor = Anchor(0, 0, false, false, 0, 1f)
58
+ private set
59
+
60
+ data class Anchor(
61
+ val positionUs: Long,
62
+ val monotonicNs: Long,
63
+ val playing: Boolean,
64
+ val ready: Boolean,
65
+ val epoch: Long,
66
+ /**
67
+ * The speed this player is actually running at.
68
+ *
69
+ * Published rather than assumed. The mix covers global project time
70
+ * with every clip's speed already compiled in, so it must run at the
71
+ * preview rate alone — never at the current clip's 2x or 0.5x. An
72
+ * anchor extrapolated at 1.0 while the player runs at anything else
73
+ * reports an offset that is partly its own arithmetic, which is the
74
+ * one kind of error a synchronisation gate cannot see.
75
+ */
76
+ val rate: Float,
77
+ )
78
+
79
+ /** One re-posted Runnable, never a fresh lambda. */
80
+ private val anchorPump = object : Runnable {
81
+ override fun run() {
82
+ if (released) return
83
+ anchor = Anchor(
84
+ positionUs = player.currentPosition * 1000,
85
+ monotonicNs = System.nanoTime(),
86
+ playing = player.isPlaying,
87
+ ready = player.playbackState == Player.STATE_READY,
88
+ epoch = epoch,
89
+ rate = player.playbackParameters.speed,
90
+ )
91
+ // The same cadence the video player's anchor uses. 25 ms was
92
+ // chosen for resolution and is being tested as a suspect for the
93
+ // renderer stall: two players posting to one looper is twice the
94
+ // main-thread traffic the editor had before music existed.
95
+ // Off in the bisect configuration that excludes it. The pump is
96
+ // one of four things music adds to the main looper.
97
+ if (PreviewConfig.positionPump) main.postDelayed(this, 50)
98
+ }
99
+ }
100
+
101
+ @Volatile private var lastError: String? = null
102
+
103
+ /**
104
+ * The epoch this player belongs to.
105
+ *
106
+ * A caller stamps every start and seek. A callback or a state read carrying
107
+ * an older epoch is from a mix that has since been replaced, and acting on
108
+ * it would move the picture to match audio that is no longer playing.
109
+ */
110
+ @Volatile var epoch: Long = 0
111
+ private set
112
+
113
+ private fun onPlayer(block: ExoPlayer.() -> Unit) {
114
+ if (released) return
115
+ if (Looper.myLooper() == Looper.getMainLooper()) player.block()
116
+ else main.post { if (!released) player.block() }
117
+ }
118
+
119
+ fun load(uriString: String, epoch: Long) {
120
+ this.epoch = epoch
121
+ lastError = null
122
+ PreviewTrace.record("audio.load") { JSONObject().put("epoch", epoch) }
123
+ // A new file has its own seek cost; carrying the old estimate forward
124
+ // would aim the first correction at the wrong place.
125
+ PreviewTrace.resetDrift()
126
+ onPlayer {
127
+ setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
128
+ prepare()
129
+ }
130
+ }
131
+
132
+ fun play(epoch: Long) {
133
+ if (!PreviewConfig.startMix) return
134
+ // A start for an epoch that has already been superseded is dropped
135
+ // rather than played: the alternative is audio from the previous edit.
136
+ PreviewTrace.record("audio.play") {
137
+ JSONObject().put("epoch", epoch).put("current", this.epoch)
138
+ .put("accepted", epoch == this.epoch)
139
+ }
140
+ if (epoch != this.epoch) return
141
+ onPlayer { play() }
142
+ }
143
+
144
+ fun pause() {
145
+ PreviewTrace.mark("audio.pause")
146
+ onPlayer { pause() }
147
+ }
148
+
149
+ fun seekTo(positionUs: Long, epoch: Long) {
150
+ PreviewTrace.record("audio.seek") {
151
+ JSONObject().put("toUs", positionUs).put("epoch", epoch)
152
+ .put("current", this.epoch).put("accepted", epoch == this.epoch)
153
+ }
154
+ if (epoch != this.epoch) return
155
+ onPlayer { seekTo(positionUs / 1000) }
156
+ }
157
+
158
+ /**
159
+ * Playback rate.
160
+ *
161
+ * Normally 1: clip speed is already compiled into the mix, so re-applying
162
+ * it here would apply it twice. It exists for the preview-rate control,
163
+ * which is a property of watching rather than of the document.
164
+ */
165
+ fun setRate(rate: Float) {
166
+ onPlayer { setPlaybackSpeed(if (rate > 0f) rate else 1f) }
167
+ }
168
+
169
+ /**
170
+ * Where the mix is, and when that was true.
171
+ *
172
+ * `monotonicNs` is sampled beside the position on purpose. A drift monitor
173
+ * that compares two positions read at unknown times is measuring its own
174
+ * scheduling jitter as much as anything else.
175
+ */
176
+ fun state(): String {
177
+ val out = JSONObject().put("epoch", epoch).put("released", released)
178
+ if (released) return out.put("ok", false).toString()
179
+ val read = {
180
+ out.put("ok", true)
181
+ .put("positionUs", player.currentPosition * 1000)
182
+ .put("monotonicNs", System.nanoTime())
183
+ .put("playing", player.isPlaying)
184
+ .put("ready", player.playbackState == Player.STATE_READY)
185
+ .put("ended", player.playbackState == Player.STATE_ENDED)
186
+ .put("bufferedUs", player.bufferedPosition * 1000)
187
+ lastError?.let { out.put("error", it) }
188
+ Unit
189
+ }
190
+ if (Looper.myLooper() == Looper.getMainLooper()) {
191
+ read()
192
+ } else {
193
+ // A state read from the JS thread has to hop and wait: reading
194
+ // ExoPlayer off its own thread is undefined, and a stale cached
195
+ // value is exactly what a drift monitor must not be given.
196
+ val latch = java.util.concurrent.CountDownLatch(1)
197
+ main.post {
198
+ if (!released) runCatching { read() }
199
+ latch.countDown()
200
+ }
201
+ if (!latch.await(50, java.util.concurrent.TimeUnit.MILLISECONDS)) {
202
+ out.put("ok", false).put("error", "timed out reading player state")
203
+ }
204
+ }
205
+ return out.toString()
206
+ }
207
+
208
+ fun release() {
209
+ if (released) return
210
+ PreviewTrace.mark("audio.release")
211
+ released = true
212
+ // On the player's own thread, explicitly. Nothing native is reachable
213
+ // from a finalizer here, for the reason the video binding documents.
214
+ if (Looper.myLooper() == Looper.getMainLooper()) player.release()
215
+ else main.post { player.release() }
216
+ }
217
+
218
+ companion object {
219
+ @Volatile private var instance: PreviewAudioPlayer? = null
220
+
221
+ /** The one preview-audio player, created on demand. */
222
+ /**
223
+ * Players ever built, players released, and how many are alive now.
224
+ *
225
+ * A rebuild replaces the mix, and the question a bounds gate asks is
226
+ * whether it also replaces the *player*. One alive at all times is the
227
+ * design; the counters are what turn that from an argument about a
228
+ * singleton into a measurement.
229
+ */
230
+ @Volatile var created = 0L
231
+ private set
232
+
233
+ @Volatile var releasedCount = 0L
234
+ private set
235
+
236
+ fun liveCount(): Int = if (instance?.released == false) 1 else 0
237
+
238
+ fun instanceCounters(): org.json.JSONObject =
239
+ org.json.JSONObject()
240
+ .put("created", created)
241
+ .put("released", releasedCount)
242
+ .put("live", liveCount())
243
+
244
+ fun require(context: Context): PreviewAudioPlayer {
245
+ instance?.let { if (!it.released) return it }
246
+ val built = build(context)
247
+ created += 1
248
+ instance = built
249
+ return built
250
+ }
251
+
252
+ fun current(): PreviewAudioPlayer? = instance?.takeIf { !it.released }
253
+
254
+ fun releaseAll() {
255
+ if (instance?.released == false) releasedCount += 1
256
+ instance?.release()
257
+ instance = null
258
+ }
259
+
260
+ private fun build(context: Context): PreviewAudioPlayer {
261
+ val latch = java.util.concurrent.CountDownLatch(1)
262
+ var built: PreviewAudioPlayer? = null
263
+ val make = {
264
+ val exo = ExoPlayer.Builder(context).build()
265
+ val wrapper = PreviewAudioPlayer(exo)
266
+ exo.addListener(
267
+ object : Player.Listener {
268
+ override fun onPlayerError(error: PlaybackException) {
269
+ // Recorded rather than thrown: a mix that will not
270
+ // open is a state the coordinator has to see, not an
271
+ // exception on somebody else's thread.
272
+ wrapper.lastError = error.errorCodeName + ": " + error.message
273
+ }
274
+ },
275
+ )
276
+ built = wrapper
277
+ // Publishing starts with the player and stops with it: an
278
+ // anchor from a released player is a position that no longer
279
+ // exists.
280
+ wrapper.main.post(wrapper.anchorPump)
281
+ latch.countDown()
282
+ }
283
+ if (Looper.myLooper() == Looper.getMainLooper()) make()
284
+ else {
285
+ Handler(Looper.getMainLooper()).post(make)
286
+ latch.await(2, java.util.concurrent.TimeUnit.SECONDS)
287
+ }
288
+ return built ?: throw IllegalStateException("preview audio player could not be created")
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,96 @@
1
+ package dev.pictor.video
2
+
3
+ import org.json.JSONObject
4
+
5
+ /**
6
+ * Which parts of the music preview exist, for bisecting a failure.
7
+ *
8
+ * The picture stops advancing when music is active and the counters say the
9
+ * *consumer* stopped: `available` reaches three and stays there while the
10
+ * decoder keeps offering. Three availability callbacks and a stalled consumer
11
+ * is what a full BufferQueue looks like — nothing latches, so nothing is
12
+ * recycled, so the producer has nowhere to put frame four.
13
+ *
14
+ * That narrows it to the render path, and the music preview added four things
15
+ * that could reach it: a second `ExoPlayer`, its position pump on the main
16
+ * looper, the stall coordinator that ticks from the video player's own pump,
17
+ * and the mix actually playing. Turning them off one at a time is the only way
18
+ * to say which, and guessing between them is how the last three "fixes" in this
19
+ * area were tuning rather than repair.
20
+ *
21
+ * Every flag defaults to on, so a build without diagnostics behaves exactly as
22
+ * production does. They are settable only through the diagnostics-gated module
23
+ * method, and the harness persists a choice so a *fresh process* can apply it —
24
+ * an intermittent failure re-measured inside a warm process is not the same
25
+ * experiment.
26
+ */
27
+ internal object PreviewConfig {
28
+
29
+ /** Create and load the second player at all. */
30
+ @Volatile var loadPlayer = true
31
+
32
+ /** Let the mix actually start when playback starts. */
33
+ @Volatile var startMix = true
34
+
35
+ /** Publish the mix's position anchor from the main looper. */
36
+ @Volatile var positionPump = true
37
+
38
+ /** Run the stall coordinator. */
39
+ @Volatile var coordinator = true
40
+
41
+ /**
42
+ * Let the drift check act on what it finds.
43
+ *
44
+ * The controlled comparison for correction-seek interference. The trace
45
+ * implicates it directly: at a seam the project clock runs at half rate for
46
+ * ~450 ms while the player applies a speed change it has already reported,
47
+ * a correction fires on that wrong reading, and the overshoot it leaves is
48
+ * larger and longer-lived than the excursion it was answering. Turning the
49
+ * correction off — while still measuring and recording what it *would* have
50
+ * done — separates the excursion from the response to it.
51
+ */
52
+ @Volatile var driftCorrection = true
53
+
54
+ /**
55
+ * Keep a copy of the mixed PCM for the attribution gate.
56
+ *
57
+ * Off by default. A 33-second stereo mix is 12.7 MB and this held one for
58
+ * the life of the process, in addition to the array being encoded — a cost
59
+ * paid by every run after the one experiment that wanted it.
60
+ */
61
+ @Volatile var retainMixedPcm = false
62
+
63
+ fun apply(json: String): JSONObject {
64
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
65
+ return try {
66
+ val o = JSONObject(json)
67
+ if (o.has("loadPlayer")) loadPlayer = o.getBoolean("loadPlayer")
68
+ if (o.has("startMix")) startMix = o.getBoolean("startMix")
69
+ if (o.has("positionPump")) positionPump = o.getBoolean("positionPump")
70
+ if (o.has("coordinator")) coordinator = o.getBoolean("coordinator")
71
+ if (o.has("driftCorrection")) driftCorrection = o.getBoolean("driftCorrection")
72
+ if (o.has("retainMixedPcm")) retainMixedPcm = o.getBoolean("retainMixedPcm")
73
+ current().put("ok", true)
74
+ } catch (e: Exception) {
75
+ JSONObject().put("ok", false).put("error", e.javaClass.simpleName)
76
+ }
77
+ }
78
+
79
+ fun current(): JSONObject =
80
+ JSONObject()
81
+ .put("loadPlayer", loadPlayer)
82
+ .put("startMix", startMix)
83
+ .put("positionPump", positionPump)
84
+ .put("coordinator", coordinator)
85
+ .put("driftCorrection", driftCorrection)
86
+ .put("retainMixedPcm", retainMixedPcm)
87
+
88
+ fun reset() {
89
+ loadPlayer = true
90
+ startMix = true
91
+ positionPump = true
92
+ coordinator = true
93
+ driftCorrection = true
94
+ retainMixedPcm = false
95
+ }
96
+ }