@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,478 @@
1
+ package dev.pictor.video
2
+
3
+ import android.os.Handler
4
+ import android.os.HandlerThread
5
+ import java.util.concurrent.atomic.AtomicLong
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * One monotonic record of what both players were doing.
11
+ *
12
+ * ## Why a buffer and not a log
13
+ *
14
+ * The question is whether the picture and the sound agree, and answering it
15
+ * needs samples close enough together to see a 40 ms disagreement. Logging that
16
+ * from JavaScript would put a bridge hop and a string format inside the
17
+ * measurement, and `uiautomator` timing — already ruled out — is coarser still
18
+ * than either. So events are written into a fixed ring on the thread that
19
+ * observes them, and drained afterwards.
20
+ *
21
+ * Everything is stamped with `System.nanoTime()`, which is the clock
22
+ * `PlaybackAnchor` publishes on and the clock `VideoFrameMetadataListener`
23
+ * reports frame release times on. One timebase, so a comparison between two
24
+ * players is a subtraction rather than an argument.
25
+ *
26
+ * ## Bounded, and it says when it overflowed
27
+ *
28
+ * A ring drops the oldest events when it wraps, and a trace that silently lost
29
+ * its middle would be worse than no trace: the gap is exactly where a
30
+ * discontinuity would have been. The sequence number is monotonic and the
31
+ * drained result reports how many were dropped, so a harness can tell a quiet
32
+ * period from a lost one.
33
+ *
34
+ * ## Off unless armed
35
+ *
36
+ * Compile-time gated by [Diagnostics] and then armed explicitly, because
37
+ * sampling two players at 50 ms is work that a preview should not be doing for
38
+ * anybody who is not measuring it.
39
+ */
40
+ internal object PreviewTrace {
41
+
42
+ /** Enough for roughly two minutes at the sampling interval, plus events. */
43
+ private const val CAPACITY = 8192
44
+
45
+ private val ring = arrayOfNulls<Event>(CAPACITY)
46
+
47
+ /** Total events ever written. The index into the ring is this modulo it. */
48
+ private val written = AtomicLong(0)
49
+
50
+ /** Armed by the harness; never on in a build without diagnostics. */
51
+ @Volatile var armed = false
52
+ internal set
53
+
54
+ /**
55
+ * Why the run stopped being trustworthy, or null.
56
+ *
57
+ * A sampler that threw and quietly disarmed left a trace that looked
58
+ * complete: the events before the fault were real, the summary computed
59
+ * happily, and nothing said the recording had stopped early. That is how a
60
+ * partial run becomes successful evidence. Set before disarming, cleared
61
+ * only by a fresh arm, and reported by every drain — a caller that ignores
62
+ * it is reading a truncated trace as a whole one.
63
+ */
64
+ @Volatile var faulted: String? = null
65
+ private set
66
+
67
+ class Event(val seq: Long, val atNs: Long, val type: String, val payload: JSONObject)
68
+
69
+ fun arm(on: Boolean) {
70
+ if (!Diagnostics.ENABLED) return
71
+ armed = on
72
+ if (on) {
73
+ faulted = null
74
+ reset()
75
+ }
76
+ }
77
+
78
+ fun reset() {
79
+ written.set(0)
80
+ for (i in ring.indices) ring[i] = null
81
+ }
82
+
83
+ /**
84
+ * Record one event.
85
+ *
86
+ * The payload is built by the caller only when armed, so an unarmed build
87
+ * pays for a volatile read and nothing else.
88
+ */
89
+ inline fun record(type: String, build: () -> JSONObject) {
90
+ if (!armed) return
91
+ put(type, build())
92
+ }
93
+
94
+ /** Record with no payload. */
95
+ fun mark(type: String) {
96
+ if (!armed) return
97
+ put(type, JSONObject())
98
+ }
99
+
100
+ fun put(type: String, payload: JSONObject) {
101
+ if (!armed) return
102
+ val seq = written.getAndIncrement()
103
+ ring[(seq % CAPACITY).toInt()] = Event(seq, System.nanoTime(), type, payload)
104
+ }
105
+
106
+ /**
107
+ * Everything recorded since `fromSeq`, oldest first.
108
+ *
109
+ * `dropped` counts events that were overwritten before this call could see
110
+ * them — a harness that ignores it is reading a trace with a hole in it.
111
+ */
112
+ fun drain(fromSeq: Long, limit: Int): JSONObject {
113
+ val total = written.get()
114
+ val oldestHeld = maxOf(0L, total - CAPACITY)
115
+ val start = maxOf(fromSeq, oldestHeld)
116
+ val dropped = maxOf(0L, oldestHeld - fromSeq)
117
+
118
+ val out = JSONArray()
119
+ var seq = start
120
+ while (seq < total && out.length() < limit) {
121
+ val event = ring[(seq % CAPACITY).toInt()]
122
+ // A slot can be null only before the ring has filled that far.
123
+ if (event != null && event.seq == seq) {
124
+ out.put(
125
+ JSONObject()
126
+ .put("seq", event.seq)
127
+ .put("atNs", event.atNs)
128
+ .put("type", event.type)
129
+ .put("v", event.payload),
130
+ )
131
+ }
132
+ seq += 1
133
+ }
134
+ return JSONObject()
135
+ // `ok` is about the drain call. `valid` is about the run: a trace
136
+ // whose sampler threw is complete up to the fault and silent after
137
+ // it, and the difference is invisible in the events themselves.
138
+ .put("ok", true)
139
+ .put("valid", faulted == null)
140
+ .put("fault", faulted ?: JSONObject.NULL)
141
+ .put("events", out)
142
+ .put("nextSeq", seq)
143
+ .put("total", total)
144
+ .put("dropped", dropped)
145
+ }
146
+
147
+ // ── the paired sampler ────────────────────────────────────────────────
148
+
149
+ private var samplerThread: HandlerThread? = null
150
+ private var samplerHandler: Handler? = null
151
+
152
+ /**
153
+ * Both players, read at the same instant, on the same clock.
154
+ *
155
+ * Neither read blocks: the video player publishes [PlaybackAnchor] and the
156
+ * mix publishes its own, both from their own threads. So a sample is two
157
+ * volatile reads and a subtraction, and the interval between them is
158
+ * nanoseconds rather than a thread hop.
159
+ *
160
+ * The video position is *extrapolated* to the sample instant through
161
+ * `positionAt`, because `currentPosition` does not advance continuously and
162
+ * comparing a stale value against a fresh one reports the staleness as
163
+ * drift. The anchor's own age is recorded alongside, so a reader can see
164
+ * how much of any offset is extrapolation.
165
+ */
166
+ private val sampler = object : Runnable {
167
+ override fun run() {
168
+ if (!armed) return
169
+ /*
170
+ Nothing here may kill the process.
171
+
172
+ It did: `FrameClock.pipeline()` was shortened from ten fields to
173
+ eight and a reader was left at index eight, so the sampler threw
174
+ `ArrayIndexOutOfBoundsException` on its first tick and Android took
175
+ the app down. From the outside that is indistinguishable from
176
+ another application taking the foreground — the previous one
177
+ surfaces — and it was twice reported as exactly that.
178
+
179
+ An instrument that can crash what it measures is worse than no
180
+ instrument, so the whole tick is guarded and a failure disarms
181
+ rather than propagates.
182
+ */
183
+ try {
184
+ sample()
185
+ } catch (e: Throwable) {
186
+ // Marked *before* disarming. A run that stopped recording must
187
+ // not be readable as one that simply had nothing more to say.
188
+ faulted = e.javaClass.simpleName + (e.message?.let { ": $it" } ?: "")
189
+ armed = false
190
+ android.util.Log.e("PictorTrace", "sampler failed; run invalid", e)
191
+ return
192
+ }
193
+ samplerHandler?.postDelayed(this, intervalMs)
194
+ }
195
+
196
+ private fun sample() {
197
+ val atNs = System.nanoTime()
198
+ val video = PlaybackAnchor.latest()
199
+ val audio = PreviewAudioPlayer.current()?.anchor
200
+
201
+ /*
202
+ Converted to project time, not read as-is.
203
+
204
+ `player.currentPosition` is position within the current window, so
205
+ comparing it against a mix that covers the whole timeline made
206
+ every seam look like a nine-second desynchronisation. The local
207
+ value is extrapolated first and converted second, because speed is
208
+ a property of the window.
209
+ */
210
+ val localUs = PlaybackAnchor.positionAt(atNs)
211
+ val videoUs = Media3Player.globalPositionUs(video.windowIndex, localUs)
212
+
213
+ val payload = JSONObject()
214
+ .put("videoUs", videoUs)
215
+ // The frame on screen, which is not the decoder's position: a
216
+ // stall shows as the two separating, and only one of them is
217
+ // what a person sees.
218
+ .put("presentedUs", PreviewSync.presentedUs() ?: -1)
219
+ // The pipeline behind that number, so a frozen reading can be
220
+ // attributed rather than guessed at.
221
+ .also { p ->
222
+ val c = FrameClock.pipeline()
223
+ p.put("fcOffered", c[0]).put("fcAvailable", c[1]).put("fcTaken", c[2])
224
+ .put("fcOrphaned", c[3]).put("fcUnpaired", c[4]).put("fcStale", c[5])
225
+ .put("fcCoalesced", c[6]).put("fcGeneration", c[7])
226
+ /*
227
+ The presented frame's identity, not just its time.
228
+
229
+ `id` advancing with an unchanged `pts` is a repeated
230
+ picture; `id` unchanged is a stale record; both advancing
231
+ with a wrong global value is a mapping fault. The position
232
+ alone cannot distinguish them, and a frozen reading was
233
+ previously reported without saying which it was.
234
+ */
235
+ PreviewSync.presentedRecord()?.let { r ->
236
+ p.put("pfId", r[0]).put("pfGen", r[1]).put("pfWindow", r[2])
237
+ .put("pfPtsUs", r[3]).put("pfGlobalUs", r[4])
238
+ .put("pfConsumedNs", r[5])
239
+ .put("pfAgeNs", atNs - r[5])
240
+ .put("pfFresh", r[0] != lastPresentedId)
241
+ lastPresentedId = r[0]
242
+ }
243
+ }
244
+ .put("videoLocalUs", localUs)
245
+ .put("videoWindow", video.windowIndex)
246
+ .put("videoRawUs", video.positionUs)
247
+ .put("videoAnchorAgeNs", atNs - video.monotonicNs)
248
+ .put("videoPlaying", video.playing)
249
+ .put("videoBuffering", video.buffering)
250
+ .put("videoGeneration", video.generation)
251
+ .put("videoRate", video.rate.toDouble())
252
+ // The other factor in the conversion. A project clock running
253
+ // at the wrong rate is either the player's speed or this one,
254
+ // and only both together say which.
255
+ .put("windowSpeed", Media3Player.windowSpeedFor(video.windowIndex).toDouble())
256
+ .put("sourceVolume", Media3Player.currentVolumeOrNull() ?: -1.0)
257
+
258
+ if (audio != null) {
259
+ // Extrapolated the same way, and only while it is actually
260
+ // playing: a paused player's position does not advance, so
261
+ // projecting it forward would invent drift.
262
+ val projected =
263
+ if (audio.playing) {
264
+ // At the player's own rate. Extrapolating at 1.0 while
265
+ // it runs at anything else puts the error into the
266
+ // measurement instead of the thing measured.
267
+ audio.positionUs +
268
+ ((atNs - audio.monotonicNs) / 1000.0 * audio.rate).toLong()
269
+ } else {
270
+ audio.positionUs
271
+ }
272
+ payload
273
+ .put("audioUs", projected)
274
+ .put("audioRawUs", audio.positionUs)
275
+ .put("audioAnchorAgeNs", atNs - audio.monotonicNs)
276
+ .put("audioPlaying", audio.playing)
277
+ .put("audioReady", audio.ready)
278
+ .put("audioEpoch", audio.epoch)
279
+ .put("audioRate", audio.rate.toDouble())
280
+ .put("offsetUs", projected - videoUs)
281
+ } else {
282
+ payload.put("audioUs", JSONObject.NULL)
283
+ }
284
+
285
+ /*
286
+ Transitions, recorded with positions.
287
+
288
+ A summary of offsets cannot say whether an excursion came from a
289
+ late pause or an early resume. These are the two instants that
290
+ distinguish them.
291
+ */
292
+ val playingNow = audio?.playing ?: false
293
+ if (playingNow != lastAudioPlaying) {
294
+ lastAudioPlaying = playingNow
295
+ put(
296
+ if (playingNow) "audio.advancing" else "audio.stopped",
297
+ JSONObject()
298
+ .put("audioUs", audio?.positionUs ?: -1)
299
+ .put("videoUs", videoUs)
300
+ .put("presentedUs", PreviewSync.presentedUs() ?: -1),
301
+ )
302
+ }
303
+
304
+ put("sample", payload)
305
+ }
306
+ }
307
+
308
+ @Volatile private var intervalMs: Long = 25
309
+
310
+ /** For detecting the instant the mix actually stops or starts advancing. */
311
+ @Volatile private var lastAudioPlaying = false
312
+
313
+ /** The last presented frame's sequence, so freshness is a comparison. */
314
+ @Volatile private var lastPresentedId = Long.MIN_VALUE
315
+
316
+ fun startSampling(everyMs: Long) {
317
+ if (!Diagnostics.ENABLED) return
318
+ stopSampling()
319
+ intervalMs = everyMs.coerceIn(10, 1000)
320
+ val thread = HandlerThread("pictor-preview-trace").also { it.start() }
321
+ samplerThread = thread
322
+ samplerHandler = Handler(thread.looper).also { it.post(sampler) }
323
+ }
324
+
325
+ fun stopSampling() {
326
+ samplerHandler?.removeCallbacksAndMessages(null)
327
+ samplerHandler = null
328
+ samplerThread?.quitSafely()
329
+ samplerThread = null
330
+ }
331
+
332
+ /**
333
+ * Compare the two players and correct the mix if it has drifted too far.
334
+ *
335
+ * Done here, natively, because both anchors are already published on one
336
+ * clock and neither read blocks. The alternative — asking from JavaScript —
337
+ * puts a bridge hop between the two positions being compared and gives the
338
+ * caller no nanosecond clock to stamp them with, so the measurement would
339
+ * carry its own scheduling jitter into the answer.
340
+ *
341
+ * The picture is authoritative: the composed-frame clock is what the rest
342
+ * of the editor follows, so the mix moves to meet it.
343
+ *
344
+ * Correction only past the tolerance. Every seek is a discontinuity in the
345
+ * sound, so a monitor that nudges on every tick produces exactly the
346
+ * artefact it exists to prevent.
347
+ */
348
+ /**
349
+ * How long a correcting seek takes to take effect, learned.
350
+ *
351
+ * A seek is not instantaneous: the mix re-buffers, and the picture keeps
352
+ * moving while it does. Measured on a Pixel 7, seeking to the picture's
353
+ * current position landed the mix about 195 ms behind it — and the monitor
354
+ * then chased that back the other way, oscillating +264, -194, -207, -178
355
+ * for as long as playback continued. Correcting to where the picture *will
356
+ * be* is what makes it converge.
357
+ *
358
+ * Learned rather than fixed: it is a property of the device, the file and
359
+ * how much of it is buffered, none of which this can know in advance.
360
+ * Clamped, because a wild estimate would be worse than none.
361
+ */
362
+ @Volatile private var seekLatencyUs: Long = 0
363
+ private const val MAX_LATENCY_US = 400_000L
364
+
365
+ /** When the last correction was issued, and what it aimed at. */
366
+ @Volatile private var lastCorrectionNs: Long = 0
367
+ @Volatile private var lastCorrectionTargetUs: Long = 0
368
+
369
+ /**
370
+ * How long to leave a correction alone before judging it.
371
+ *
372
+ * A measurement taken mid-seek reads the seek, not the drift. Without this
373
+ * the monitor corrected every tick and never let anything settle.
374
+ */
375
+ private const val SETTLE_NS = 900_000_000L
376
+
377
+ fun driftCheck(toleranceUs: Long): JSONObject {
378
+ val out = JSONObject()
379
+ val player = PreviewAudioPlayer.current()
380
+ ?: return out.put("ok", false).put("reason", "no mixed player")
381
+ val audio = player.anchor
382
+ if (!audio.playing) return out.put("ok", true).put("acted", false).put("reason", "not playing")
383
+
384
+ val video = PlaybackAnchor.latest()
385
+ if (!video.playing) return out.put("ok", true).put("acted", false).put("reason", "video paused")
386
+ if (PreviewSync.stalled) {
387
+ // The coordinator owns this. A correction now would aim at a
388
+ // position the picture is about to leave, which is how the drift
389
+ // monitor previously turned a stall into an oscillation.
390
+ return out.put("ok", true).put("acted", false).put("reason", "stall held")
391
+ }
392
+ if (video.buffering) {
393
+ // The picture is stalled and the sound is not. Correcting now would
394
+ // aim at a position the video is about to leave; this is the
395
+ // condition that creates the drift, not a moment to measure it.
396
+ return out.put("ok", true).put("acted", false).put("reason", "video buffering")
397
+ }
398
+
399
+ val atNs = System.nanoTime()
400
+ val videoUs = Media3Player.globalPositionUs(
401
+ video.windowIndex,
402
+ PlaybackAnchor.positionAt(atNs),
403
+ )
404
+ val audioUs = audio.positionUs + (atNs - audio.monotonicNs) / 1000
405
+ val driftUs = audioUs - videoUs
406
+
407
+ out.put("ok", true)
408
+ .put("driftUs", driftUs)
409
+ .put("videoUs", videoUs)
410
+ .put("audioUs", audioUs)
411
+ .put("toleranceUs", toleranceUs)
412
+ .put("latencyUs", seekLatencyUs)
413
+
414
+ val sinceCorrection = atNs - lastCorrectionNs
415
+ if (lastCorrectionNs != 0L && sinceCorrection < SETTLE_NS) {
416
+ return out.put("acted", false).put("reason", "settling")
417
+ }
418
+
419
+ /*
420
+ Learn from the last correction before deciding on this one.
421
+
422
+ If the previous seek left the mix behind, it undershot by exactly the
423
+ time the seek took. That residual is the latency, smoothed so a single
424
+ noisy sample cannot swing the next target.
425
+ */
426
+ if (lastCorrectionNs != 0L) {
427
+ val residual = -driftUs
428
+ run {
429
+ /*
430
+ Most of the residual, immediately, and in both directions.
431
+
432
+ The residual *is* the error the last correction left, so the
433
+ estimate that would have been right is `latency + residual`.
434
+ Learning only when the mix landed *behind* made the estimate
435
+ ratchet upward — measured, it ran to 344 ms and then overshot
436
+ the other way by 140 ms with no path back down.
437
+ */
438
+ seekLatencyUs = (seekLatencyUs + residual * 4 / 5)
439
+ .coerceIn(0L, MAX_LATENCY_US)
440
+ }
441
+ }
442
+
443
+ if (Math.abs(driftUs) <= toleranceUs) return out.put("acted", false)
444
+ /*
445
+ Measured either way, acted on only when enabled.
446
+
447
+ The comparison needs the excursion recorded identically in both arms;
448
+ returning before the drift is computed would make the disabled arm
449
+ look like a run with no drift rather than a run with no correction.
450
+ */
451
+ if (!PreviewConfig.driftCorrection) {
452
+ record("drift.suppressed") {
453
+ JSONObject().put("driftUs", driftUs).put("toleranceUs", toleranceUs)
454
+ }
455
+ return out.put("acted", false).put("reason", "correction disabled")
456
+ }
457
+
458
+ // Where the picture will be once the seek lands, not where it is now.
459
+ val target = videoUs + seekLatencyUs
460
+ lastCorrectionNs = atNs
461
+ lastCorrectionTargetUs = target
462
+ // Stamped with the epoch the mix is on, so a correction computed for a
463
+ // mix that has since been replaced is dropped by the player.
464
+ player.seekTo(target, audio.epoch)
465
+ record("drift.correct") {
466
+ JSONObject().put("driftUs", driftUs).put("toUs", target)
467
+ .put("latencyUs", seekLatencyUs).put("epoch", audio.epoch)
468
+ }
469
+ return out.put("acted", true).put("targetUs", target)
470
+ }
471
+
472
+ /** Forget what was learned; a new mix is a new file with its own latency. */
473
+ fun resetDrift() {
474
+ seekLatencyUs = 0
475
+ lastCorrectionNs = 0
476
+ lastCorrectionTargetUs = 0
477
+ }
478
+ }