@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- 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
|
+
}
|