@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,264 @@
|
|
|
1
|
+
import { NO_SURFACE } from './playlistSession'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who is allowed to create a presentation surface, and when.
|
|
5
|
+
*
|
|
6
|
+
* There is one external texture in the process, so there is one of these. Not
|
|
7
|
+
* one per hook instance: a hook is remounted by a navigation, a fast refresh or
|
|
8
|
+
* a rotation, and each remount would bring its own lock and its own idea of
|
|
9
|
+
* what is in flight. Two creates then overlap, each registers a generation, and
|
|
10
|
+
* the second overwrites the native handles the first is still holding — the GL
|
|
11
|
+
* texture and `SurfaceTexture` from the first are then unreachable and never
|
|
12
|
+
* released. The symptom is a hook reporting one generation while the adapter
|
|
13
|
+
* has moved on to another, with an abandoned surface in between.
|
|
14
|
+
*
|
|
15
|
+
* ## The generation is native's, and only native's
|
|
16
|
+
*
|
|
17
|
+
* `create()` returns the generation `VideoSurface.register` assigned. This
|
|
18
|
+
* module publishes that number unchanged and keeps no counter of its own — a
|
|
19
|
+
* second counter is a second answer to "which surface is live", and the whole
|
|
20
|
+
* class of bug here is two answers to one question.
|
|
21
|
+
*
|
|
22
|
+
* ## A superseded attempt is retired, not ignored
|
|
23
|
+
*
|
|
24
|
+
* An attempt whose owner went away while it was on the UI runtime still
|
|
25
|
+
* *succeeds*: the texture exists, the `Surface` is registered, Media3 could
|
|
26
|
+
* attach to it. Ignoring its report leaves exactly that — a live, registered
|
|
27
|
+
* surface nobody will ever draw or release. So it is released, on the runtime
|
|
28
|
+
* that owns the GL context, before anything else is started.
|
|
29
|
+
*
|
|
30
|
+
* ## Serialised, and retirement does not unlock
|
|
31
|
+
*
|
|
32
|
+
* A request that arrives while an attempt is unresolved is queued, and
|
|
33
|
+
* backgrounding or unmounting does **not** release the lock: the earlier
|
|
34
|
+
* attempt is still out there on the UI runtime and will still come back. The
|
|
35
|
+
* queue is one deep, because every waiting request wants the same thing.
|
|
36
|
+
*
|
|
37
|
+
* Effects are injected so the interleavings are deterministic in a test rather
|
|
38
|
+
* than a matter of timing on a device.
|
|
39
|
+
*
|
|
40
|
+
* @experimental
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** What native `create()` reports. @experimental */
|
|
44
|
+
export interface SurfaceCreateResult {
|
|
45
|
+
ok: boolean
|
|
46
|
+
/** The generation `register` assigned, or 0 on failure. */
|
|
47
|
+
generation: number
|
|
48
|
+
textureId: number
|
|
49
|
+
error: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @experimental */
|
|
53
|
+
export interface SurfaceOwnerEffects {
|
|
54
|
+
/**
|
|
55
|
+
* Schedule a create on the UI runtime.
|
|
56
|
+
*
|
|
57
|
+
* Must eventually call {@link SurfaceOwner.complete}, whatever happens.
|
|
58
|
+
*/
|
|
59
|
+
create(): void
|
|
60
|
+
/**
|
|
61
|
+
* Release a specific generation, on the runtime that owns the GL context.
|
|
62
|
+
*
|
|
63
|
+
* Takes a generation rather than "the current surface" so a late attempt
|
|
64
|
+
* cannot release the surface that replaced it.
|
|
65
|
+
*/
|
|
66
|
+
retire(generation: number): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @experimental */
|
|
70
|
+
export interface SurfaceReadiness {
|
|
71
|
+
/** The live generation, exactly as native reported it. 0 when there is none. */
|
|
72
|
+
generation: number
|
|
73
|
+
/** The last failure, or empty. */
|
|
74
|
+
error: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @experimental */
|
|
78
|
+
export interface SurfaceOwner {
|
|
79
|
+
readonly readiness: SurfaceReadiness
|
|
80
|
+
/** Whether an attempt is on the UI runtime right now. */
|
|
81
|
+
readonly busy: boolean
|
|
82
|
+
/** Generations created by a superseded attempt and released. Diagnostics. */
|
|
83
|
+
readonly retired: number
|
|
84
|
+
/**
|
|
85
|
+
* Ask for a surface on behalf of `token`.
|
|
86
|
+
*
|
|
87
|
+
* Returns false when the request was queued or refused rather than started.
|
|
88
|
+
*/
|
|
89
|
+
request(token: number): boolean
|
|
90
|
+
/** That owner is gone. Does not unlock: an unresolved attempt is still out there. */
|
|
91
|
+
retireOwner(token: number): void
|
|
92
|
+
/** An attempt finished. */
|
|
93
|
+
complete(result: SurfaceCreateResult): void
|
|
94
|
+
/** Adopt a surface that already exists, without creating one. */
|
|
95
|
+
adopt(generation: number, token: number): void
|
|
96
|
+
subscribe(listener: () => void): () => void
|
|
97
|
+
/** Point the owner at the newest caller's effects. */
|
|
98
|
+
useEffects(next: SurfaceOwnerEffects): void
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @experimental */
|
|
102
|
+
export function createSurfaceOwner(initial: SurfaceOwnerEffects): SurfaceOwner {
|
|
103
|
+
// Mutable, because the singleton outlives every hook that uses it and each
|
|
104
|
+
// one brings its own closure over the published handle and the texture size.
|
|
105
|
+
// The newest is the right one: they all drive the same native object.
|
|
106
|
+
let effects = initial
|
|
107
|
+
let readiness: SurfaceReadiness = { generation: NO_SURFACE, error: '' }
|
|
108
|
+
let busy = false
|
|
109
|
+
let retired = 0
|
|
110
|
+
/** The owner the in-flight attempt was started for. */
|
|
111
|
+
let activeToken: number | null = null
|
|
112
|
+
/**
|
|
113
|
+
* Whether that owner went away while its attempt was on the UI runtime.
|
|
114
|
+
*
|
|
115
|
+
* A flag rather than a comparison against whoever is current now: a *second*
|
|
116
|
+
* hook asking for a surface does not invalidate the one being built, because
|
|
117
|
+
* they share it. Only the asking owner disappearing does. Comparing against
|
|
118
|
+
* the current token retired a perfectly good surface every time a remount
|
|
119
|
+
* overlapped a create, which is a leak dressed as caution.
|
|
120
|
+
*/
|
|
121
|
+
let activeRetired = false
|
|
122
|
+
/** The owner currently entitled to a surface. */
|
|
123
|
+
let currentToken: number | null = null
|
|
124
|
+
/** One waiting request. Every waiter wants the same thing. */
|
|
125
|
+
let queuedToken: number | null = null
|
|
126
|
+
const listeners = new Set<() => void>()
|
|
127
|
+
|
|
128
|
+
const emit = () => {
|
|
129
|
+
for (const listener of listeners) listener()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const publish = (next: SurfaceReadiness) => {
|
|
133
|
+
if (next.generation === readiness.generation && next.error === readiness.error) return
|
|
134
|
+
readiness = next
|
|
135
|
+
emit()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const start = (token: number) => {
|
|
139
|
+
busy = true
|
|
140
|
+
activeToken = token
|
|
141
|
+
activeRetired = false
|
|
142
|
+
effects.create()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const owner: SurfaceOwner = {
|
|
146
|
+
get readiness() {
|
|
147
|
+
return readiness
|
|
148
|
+
},
|
|
149
|
+
get busy() {
|
|
150
|
+
return busy
|
|
151
|
+
},
|
|
152
|
+
get retired() {
|
|
153
|
+
return retired
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
request(token) {
|
|
157
|
+
currentToken = token
|
|
158
|
+
if (readiness.generation !== NO_SURFACE) return false
|
|
159
|
+
if (busy) {
|
|
160
|
+
queuedToken = token
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
start(token)
|
|
164
|
+
return true
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
retireOwner(token) {
|
|
168
|
+
if (activeToken === token) activeRetired = true
|
|
169
|
+
if (currentToken === token) currentToken = null
|
|
170
|
+
if (queuedToken === token) queuedToken = null
|
|
171
|
+
// Deliberately not `busy = false`. The attempt is on the UI runtime and
|
|
172
|
+
// will come back; unlocking here is what lets a second create overlap it.
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
adopt(generation, token) {
|
|
176
|
+
currentToken = token
|
|
177
|
+
if (generation === NO_SURFACE) return
|
|
178
|
+
publish({ generation, error: '' })
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
complete(result) {
|
|
182
|
+
const wasRetired = activeRetired
|
|
183
|
+
const hasOwner = currentToken !== null
|
|
184
|
+
busy = false
|
|
185
|
+
activeToken = null
|
|
186
|
+
activeRetired = false
|
|
187
|
+
|
|
188
|
+
if (result.ok && result.generation > NO_SURFACE) {
|
|
189
|
+
// Superseded means nobody is left who asked for this: the owner that
|
|
190
|
+
// started it retired, or every owner has gone. Not "somebody else asked
|
|
191
|
+
// in the meantime" — they want the same surface.
|
|
192
|
+
const superseded = wasRetired || !hasOwner
|
|
193
|
+
const stale = result.generation < readiness.generation
|
|
194
|
+
if (superseded || stale) {
|
|
195
|
+
// It exists and is registered, so it has to be released rather than
|
|
196
|
+
// forgotten. Releasing by generation means a late attempt cannot take
|
|
197
|
+
// down the surface that replaced it.
|
|
198
|
+
retired += 1
|
|
199
|
+
effects.retire(result.generation)
|
|
200
|
+
} else {
|
|
201
|
+
publish({ generation: result.generation, error: '' })
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
publish({
|
|
205
|
+
generation: readiness.generation,
|
|
206
|
+
error: result.error || 'surface create failed',
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const next = queuedToken
|
|
211
|
+
queuedToken = null
|
|
212
|
+
// Only if there is still no surface: a queued request that has since been
|
|
213
|
+
// satisfied by the attempt that just finished wants nothing more.
|
|
214
|
+
if (next !== null && readiness.generation === NO_SURFACE && next === currentToken) {
|
|
215
|
+
start(next)
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
useEffects(next) {
|
|
220
|
+
effects = next
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
subscribe(listener) {
|
|
224
|
+
listeners.add(listener)
|
|
225
|
+
return () => {
|
|
226
|
+
listeners.delete(listener)
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return owner
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The process-wide owner, created on first use.
|
|
236
|
+
*
|
|
237
|
+
* A module-level singleton because the thing it guards — the one external
|
|
238
|
+
* texture — is one per process. `resetSurfaceOwnerForTests` exists so a test can
|
|
239
|
+
* start from a known state; nothing in production calls it.
|
|
240
|
+
*
|
|
241
|
+
* @experimental
|
|
242
|
+
*/
|
|
243
|
+
let singleton: SurfaceOwner | null = null
|
|
244
|
+
|
|
245
|
+
/** @experimental */
|
|
246
|
+
export function surfaceOwner(effects: SurfaceOwnerEffects): SurfaceOwner {
|
|
247
|
+
if (!singleton) singleton = createSurfaceOwner(effects)
|
|
248
|
+
else singleton.useEffects(effects)
|
|
249
|
+
return singleton
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @experimental */
|
|
253
|
+
export function resetSurfaceOwnerForTests(): void {
|
|
254
|
+
singleton = null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Fresh token for a hook instance. Only ever increases. @experimental */
|
|
258
|
+
let nextToken = 0
|
|
259
|
+
|
|
260
|
+
/** @experimental */
|
|
261
|
+
export function nextOwnerToken(): number {
|
|
262
|
+
nextToken += 1
|
|
263
|
+
return nextToken
|
|
264
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding that a restored surface is not actually showing anything.
|
|
3
|
+
*
|
|
4
|
+
* After a background, a picker, or any other trip away from the editor, the
|
|
5
|
+
* player and the surface have to find each other again. When that handshake
|
|
6
|
+
* half-succeeds the result is the worst kind of failure: audio plays, the clock
|
|
7
|
+
* advances, no error is reported, and the picture is black. Nothing in the
|
|
8
|
+
* player's own state says anything is wrong, because from its point of view
|
|
9
|
+
* nothing is.
|
|
10
|
+
*
|
|
11
|
+
* So it is decided from the counters instead, and the counters are already
|
|
12
|
+
* separated into the four things that can go wrong — see `Media3State`, where
|
|
13
|
+
* `framesOffered` is the decoder saying a frame is coming and `framesAvailable`
|
|
14
|
+
* is the surface saying one arrived.
|
|
15
|
+
*
|
|
16
|
+
* The point of naming the branch is that the repairs differ. Re-running the
|
|
17
|
+
* handshake fixes a stale generation and does nothing at all for a consumer
|
|
18
|
+
* that has stopped pulling, and "retry everything" would have made both look
|
|
19
|
+
* like the same intermittent bug.
|
|
20
|
+
*
|
|
21
|
+
* @experimental
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How long a stall must persist before it counts. @experimental */
|
|
25
|
+
export const RECOVERY_GRACE_MS = 1500
|
|
26
|
+
|
|
27
|
+
/** What a sample of the pipeline looked like at one instant. @experimental */
|
|
28
|
+
export interface RecoverySample {
|
|
29
|
+
atMs: number
|
|
30
|
+
/** The player's own clock. Advancing means audio is being produced. */
|
|
31
|
+
positionUs: number
|
|
32
|
+
/** Media3 announcing frames. */
|
|
33
|
+
framesOffered: number
|
|
34
|
+
/** The SurfaceTexture receiving them. */
|
|
35
|
+
framesAvailable: number
|
|
36
|
+
/** Frames the consumer actually took. */
|
|
37
|
+
framesLatched: number
|
|
38
|
+
/** Announcements for a generation already retired. */
|
|
39
|
+
framesRejected: number
|
|
40
|
+
/** The generation the producer is filling. */
|
|
41
|
+
pendingGeneration: number
|
|
42
|
+
/** The generation the consumer is watching. */
|
|
43
|
+
consumerGeneration: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which half of the pipeline is broken, or `null` when it is healthy.
|
|
48
|
+
*
|
|
49
|
+
* @experimental
|
|
50
|
+
*/
|
|
51
|
+
export type StallKind =
|
|
52
|
+
/** Frames arrive and nobody takes them: the consumer or its frame loop. */
|
|
53
|
+
| 'consumer'
|
|
54
|
+
/** Producer and consumer disagree about which surface: a stale handshake. */
|
|
55
|
+
| 'handshake'
|
|
56
|
+
/** The player is running but nothing reaches the surface: attachment. */
|
|
57
|
+
| 'attachment'
|
|
58
|
+
/** Frames are latched and the picture is still wrong: scene or image. */
|
|
59
|
+
| 'resource'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Name the branch from two samples.
|
|
63
|
+
*
|
|
64
|
+
* `null` means nothing is wrong *that this can see* — including the ordinary
|
|
65
|
+
* case of a player that is simply not advancing, which is what a paused editor
|
|
66
|
+
* looks like and is not a fault.
|
|
67
|
+
*
|
|
68
|
+
* @experimental
|
|
69
|
+
*/
|
|
70
|
+
export function classifyStall(since: RecoverySample, now: RecoverySample): StallKind | null {
|
|
71
|
+
// A clock that is not moving is not a stall. Nothing is expected of a player
|
|
72
|
+
// that is paused, ended or buffering, and calling that a fault would make
|
|
73
|
+
// every pause look like a broken surface.
|
|
74
|
+
if (now.positionUs <= since.positionUs) return null
|
|
75
|
+
|
|
76
|
+
// Checked before anything else: when the two sides disagree about the
|
|
77
|
+
// generation, every other counter is describing a surface nobody is watching.
|
|
78
|
+
if (
|
|
79
|
+
now.pendingGeneration !== now.consumerGeneration ||
|
|
80
|
+
now.framesRejected > since.framesRejected
|
|
81
|
+
) {
|
|
82
|
+
return 'handshake'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const latched = now.framesLatched > since.framesLatched
|
|
86
|
+
if (latched) {
|
|
87
|
+
// The pipeline is delivering. Whatever is wrong is further along — the
|
|
88
|
+
// scene, the image, the matrix — and re-running the handshake would only
|
|
89
|
+
// interrupt something that is working.
|
|
90
|
+
return 'resource'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const arrived = now.framesAvailable > since.framesAvailable
|
|
94
|
+
if (arrived) return 'consumer'
|
|
95
|
+
|
|
96
|
+
// The decoder may or may not be announcing; either way nothing reached the
|
|
97
|
+
// surface, so the player is not attached to the one being watched.
|
|
98
|
+
return 'attachment'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the stall has lasted long enough to act on.
|
|
103
|
+
*
|
|
104
|
+
* A grace period because none of this is instantaneous: a surface is recreated,
|
|
105
|
+
* a playlist is reconciled and the first frame decoded, and a check that fired
|
|
106
|
+
* inside that window would recover a pipeline that was about to work.
|
|
107
|
+
*
|
|
108
|
+
* @experimental
|
|
109
|
+
*/
|
|
110
|
+
export function shouldRecover(
|
|
111
|
+
since: RecoverySample,
|
|
112
|
+
now: RecoverySample,
|
|
113
|
+
graceMs: number = RECOVERY_GRACE_MS,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (now.atMs - since.atMs < graceMs) return false
|
|
116
|
+
const kind = classifyStall(since, now)
|
|
117
|
+
// `resource` is deliberately excluded: frames are being latched, so the
|
|
118
|
+
// handshake this recovers is not what is wrong, and retrying it would blank a
|
|
119
|
+
// working picture on a timer.
|
|
120
|
+
return kind === 'consumer' || kind === 'handshake' || kind === 'attachment'
|
|
121
|
+
}
|
package/src/syncTrace.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tick synchronisation forensics.
|
|
3
|
+
*
|
|
4
|
+
* A single offset number cannot say *why* video is late, and optimising against
|
|
5
|
+
* it invites exactly the wrong fixes — presenting future frames, nudging the
|
|
6
|
+
* clock, delaying audio. Each of those improves the number and worsens the
|
|
7
|
+
* playback.
|
|
8
|
+
*
|
|
9
|
+
* So every tick is decomposed into causes that can be acted on separately:
|
|
10
|
+
*
|
|
11
|
+
* - **Frame quantisation** — video only changes every frame period, so the
|
|
12
|
+
* picture is always somewhat behind the audio instant. Bounded by one frame
|
|
13
|
+
* by definition, and not a defect.
|
|
14
|
+
* - **Decoder lateness** — the frame that *should* be showing had not been
|
|
15
|
+
* decoded yet. Fix by adjusting the bounded lead or queue depth.
|
|
16
|
+
* - **Scheduler lateness** — the right frame *was* available and was not
|
|
17
|
+
* selected. Fix the missed tick or the contention causing it.
|
|
18
|
+
* - **Clock correction** — how far each raw `AudioTrack` report moves the
|
|
19
|
+
* extrapolated position. Large jumps mean stale timestamps or a bad baseline.
|
|
20
|
+
*
|
|
21
|
+
* Pure, so the decomposition is tested rather than trusted.
|
|
22
|
+
*
|
|
23
|
+
* @experimental
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface TickSample {
|
|
27
|
+
/** Authoritative extrapolated audio time. */
|
|
28
|
+
audioUs: number
|
|
29
|
+
/** Age of the newest raw `getTimestamp()` report at this tick. */
|
|
30
|
+
rawAgeUs: number
|
|
31
|
+
/** How far the newest raw report moved the extrapolated position. */
|
|
32
|
+
correctionUs: number
|
|
33
|
+
/** Newest decoded frame at or before `audioUs`, or null if none is eligible. */
|
|
34
|
+
newestEligiblePtsUs: number | null
|
|
35
|
+
/** What was actually presented, or null. */
|
|
36
|
+
presentedPtsUs: number | null
|
|
37
|
+
/** Interval since the previous tick. */
|
|
38
|
+
tickIntervalUs: number
|
|
39
|
+
queueDepth: number
|
|
40
|
+
decoderLeadUs: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SyncTrace {
|
|
44
|
+
readonly frameUs: number
|
|
45
|
+
quantisation: number[]
|
|
46
|
+
decoderLate: number[]
|
|
47
|
+
schedulerLate: number[]
|
|
48
|
+
correction: number[]
|
|
49
|
+
presentedOffset: number[]
|
|
50
|
+
tickInterval: number[]
|
|
51
|
+
ticks: number
|
|
52
|
+
decoderLateTicks: number
|
|
53
|
+
schedulerLateTicks: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MAX_SAMPLES = 20_000
|
|
57
|
+
|
|
58
|
+
export function createSyncTrace(fps: number): SyncTrace {
|
|
59
|
+
const rate = Number.isFinite(fps) && fps > 0 ? fps : 30
|
|
60
|
+
return {
|
|
61
|
+
frameUs: Math.round(1_000_000 / rate),
|
|
62
|
+
quantisation: [],
|
|
63
|
+
decoderLate: [],
|
|
64
|
+
schedulerLate: [],
|
|
65
|
+
correction: [],
|
|
66
|
+
presentedOffset: [],
|
|
67
|
+
tickInterval: [],
|
|
68
|
+
ticks: 0,
|
|
69
|
+
decoderLateTicks: 0,
|
|
70
|
+
schedulerLateTicks: 0,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The frame that ought to be on screen at `audioUs`.
|
|
76
|
+
*
|
|
77
|
+
* Assumes a regular frame grid anchored at zero, which the controlled fixture
|
|
78
|
+
* has. On a variable-rate source this is an approximation and the quantisation
|
|
79
|
+
* figure should be read as indicative — which is why it is stated here rather
|
|
80
|
+
* than left for a reader to infer from a clean-looking number.
|
|
81
|
+
*/
|
|
82
|
+
export function expectedPtsAt(audioUs: number, frameUs: number): number {
|
|
83
|
+
if (audioUs <= 0 || frameUs <= 0) return 0
|
|
84
|
+
return Math.floor(audioUs / frameUs) * frameUs
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function record(trace: SyncTrace, sample: TickSample): SyncTrace {
|
|
88
|
+
if (trace.ticks >= MAX_SAMPLES) return { ...trace, ticks: trace.ticks + 1 }
|
|
89
|
+
|
|
90
|
+
const expected = expectedPtsAt(sample.audioUs, trace.frameUs)
|
|
91
|
+
const next: SyncTrace = {
|
|
92
|
+
...trace,
|
|
93
|
+
ticks: trace.ticks + 1,
|
|
94
|
+
quantisation: [...trace.quantisation, sample.audioUs - expected],
|
|
95
|
+
correction: [...trace.correction, Math.abs(sample.correctionUs)],
|
|
96
|
+
tickInterval: [...trace.tickInterval, sample.tickIntervalUs],
|
|
97
|
+
decoderLate: trace.decoderLate,
|
|
98
|
+
schedulerLate: trace.schedulerLate,
|
|
99
|
+
presentedOffset: trace.presentedOffset,
|
|
100
|
+
decoderLateTicks: trace.decoderLateTicks,
|
|
101
|
+
schedulerLateTicks: trace.schedulerLateTicks,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Decoder lateness: the frame that should be showing was not available.
|
|
105
|
+
// Measured as how far short of `expected` the newest eligible frame fell.
|
|
106
|
+
const eligible = sample.newestEligiblePtsUs
|
|
107
|
+
if (eligible === null || eligible < expected) {
|
|
108
|
+
const shortfall = eligible === null ? sample.audioUs : expected - eligible
|
|
109
|
+
next.decoderLate = [...trace.decoderLate, shortfall]
|
|
110
|
+
next.decoderLateTicks = trace.decoderLateTicks + 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Scheduler lateness: an eligible frame existed and something older — or
|
|
114
|
+
// nothing — was shown instead. This is the category that means a tick was
|
|
115
|
+
// missed rather than a frame being absent.
|
|
116
|
+
if (eligible !== null && (sample.presentedPtsUs === null || sample.presentedPtsUs < eligible)) {
|
|
117
|
+
const behind = eligible - (sample.presentedPtsUs ?? 0)
|
|
118
|
+
next.schedulerLate = [...trace.schedulerLate, behind]
|
|
119
|
+
next.schedulerLateTicks = trace.schedulerLateTicks + 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (sample.presentedPtsUs !== null) {
|
|
123
|
+
next.presentedOffset = [...trace.presentedOffset, sample.presentedPtsUs - sample.audioUs]
|
|
124
|
+
}
|
|
125
|
+
return next
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface Distribution {
|
|
129
|
+
n: number
|
|
130
|
+
p50: number
|
|
131
|
+
p95: number
|
|
132
|
+
p99: number
|
|
133
|
+
max: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function distribution(values: readonly number[]): Distribution {
|
|
137
|
+
if (values.length === 0) return { n: 0, p50: 0, p95: 0, p99: 0, max: 0 }
|
|
138
|
+
const abs = values.map(Math.abs).sort((a, b) => a - b)
|
|
139
|
+
const at = (q: number) => abs[Math.min(abs.length - 1, Math.floor(q * abs.length))]!
|
|
140
|
+
return { n: abs.length, p50: at(0.5), p95: at(0.95), p99: at(0.99), max: abs[abs.length - 1]! }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface SyncReport {
|
|
144
|
+
quantisation: Distribution
|
|
145
|
+
decoderLate: Distribution
|
|
146
|
+
schedulerLate: Distribution
|
|
147
|
+
correction: Distribution
|
|
148
|
+
presentedOffset: Distribution
|
|
149
|
+
tickInterval: Distribution
|
|
150
|
+
ticks: number
|
|
151
|
+
decoderLateTicks: number
|
|
152
|
+
schedulerLateTicks: number
|
|
153
|
+
frameUs: number
|
|
154
|
+
/** Which cause dominates, so the fix is chosen by evidence. */
|
|
155
|
+
dominant: 'none' | 'decoder' | 'scheduler' | 'quantisation'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function syncReport(trace: SyncTrace): SyncReport {
|
|
159
|
+
const decoderLate = distribution(trace.decoderLate)
|
|
160
|
+
const schedulerLate = distribution(trace.schedulerLate)
|
|
161
|
+
const quantisation = distribution(trace.quantisation)
|
|
162
|
+
const presentedOffset = distribution(trace.presentedOffset)
|
|
163
|
+
|
|
164
|
+
// Attribute by how often each fires and how badly, not by which p95 is
|
|
165
|
+
// numerically larger: a rare huge decoder stall and a constant one-frame
|
|
166
|
+
// scheduler lag need different fixes.
|
|
167
|
+
const decoderShare = trace.ticks === 0 ? 0 : trace.decoderLateTicks / trace.ticks
|
|
168
|
+
const schedulerShare = trace.ticks === 0 ? 0 : trace.schedulerLateTicks / trace.ticks
|
|
169
|
+
let dominant: SyncReport['dominant'] = 'none'
|
|
170
|
+
if (presentedOffset.p95 > trace.frameUs) {
|
|
171
|
+
if (decoderShare >= schedulerShare && decoderShare > 0.01) dominant = 'decoder'
|
|
172
|
+
else if (schedulerShare > 0.01) dominant = 'scheduler'
|
|
173
|
+
else dominant = 'quantisation'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
quantisation,
|
|
178
|
+
decoderLate,
|
|
179
|
+
schedulerLate,
|
|
180
|
+
correction: distribution(trace.correction),
|
|
181
|
+
presentedOffset,
|
|
182
|
+
tickInterval: distribution(trace.tickInterval),
|
|
183
|
+
ticks: trace.ticks,
|
|
184
|
+
decoderLateTicks: trace.decoderLateTicks,
|
|
185
|
+
schedulerLateTicks: trace.schedulerLateTicks,
|
|
186
|
+
frameUs: trace.frameUs,
|
|
187
|
+
dominant,
|
|
188
|
+
}
|
|
189
|
+
}
|