@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,680 @@
|
|
|
1
|
+
import type { SpikeSample } from './compositionSpike'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Score the picture where it is handed to composition.
|
|
5
|
+
*
|
|
6
|
+
* ## The boundary, stated
|
|
7
|
+
*
|
|
8
|
+
* **Submission**, not scanout. A frame is scored at the instant composition
|
|
9
|
+
* begins consuming it; the compositor presents it some time later and nothing
|
|
10
|
+
* in this process observes that. The delay is common to every frame, so it
|
|
11
|
+
* shifts the whole series rather than spreading it — but it is not measured
|
|
12
|
+
* here and is not subtracted from anything.
|
|
13
|
+
*
|
|
14
|
+
* ## Why an interval rather than a point
|
|
15
|
+
*
|
|
16
|
+
* A frame is not a sample of one instant; it is the correct picture for a span
|
|
17
|
+
* of project time. Comparing a continuous audio clock against a single
|
|
18
|
+
* timestamp charges the frame's own duration to synchronisation, which is why
|
|
19
|
+
* the 0.5x clip — whose frames are correct for 66.7 ms each — looked worse than
|
|
20
|
+
* the 1x clip at identical quality.
|
|
21
|
+
*
|
|
22
|
+
* So a submitted frame carries `[ptsUs, ptsUs + intervalUs)`, and the error is
|
|
23
|
+
* how far outside that span the audio clock sits. Zero inside, and the distance
|
|
24
|
+
* to the nearer edge outside. The interval comes from the measured cadence,
|
|
25
|
+
* which for a clip of speed `s` and source interval `i` is `i / s` — 33.3 ms at
|
|
26
|
+
* 1x, 66.7 ms at 0.5x.
|
|
27
|
+
*
|
|
28
|
+
* @experimental
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** One submission, as the consumer recorded it. @experimental */
|
|
32
|
+
export interface SubmissionRecord {
|
|
33
|
+
frameId: number
|
|
34
|
+
epoch: number
|
|
35
|
+
ptsUs: number
|
|
36
|
+
intervalUs: number
|
|
37
|
+
submittedNs: number
|
|
38
|
+
/** What the texture reported holding at acquisition. */
|
|
39
|
+
texturePtsUs: number
|
|
40
|
+
/** Which ring slot carried it. */
|
|
41
|
+
slot: number
|
|
42
|
+
occupancy: number
|
|
43
|
+
/**
|
|
44
|
+
* The frame index read out of the slot's pixels when it was acquired.
|
|
45
|
+
*
|
|
46
|
+
* -1 when the probe was off or the band could not be read; `markerFailure`
|
|
47
|
+
* says which.
|
|
48
|
+
*/
|
|
49
|
+
acquiredMarker: number
|
|
50
|
+
/** The frame index read out of the picture actually handed to composition. */
|
|
51
|
+
submittedMarker: number
|
|
52
|
+
/** Empty when the band was read; otherwise why it was not. */
|
|
53
|
+
markerFailure: string
|
|
54
|
+
/**
|
|
55
|
+
* The picture's whole life, so one row can be attributed.
|
|
56
|
+
*
|
|
57
|
+
* When it was acquired, when it was due for presentation, when it was
|
|
58
|
+
* promoted. Without these, a late submission cannot be separated into a late
|
|
59
|
+
* arrival, a long wait for its due time, and a slow promotion.
|
|
60
|
+
*/
|
|
61
|
+
acquiredNs: number
|
|
62
|
+
dueNs: number
|
|
63
|
+
promotedNs: number
|
|
64
|
+
/**
|
|
65
|
+
* Rendering error against the frame the submitted band names: background
|
|
66
|
+
* level in 0..255, bar position in 0..1 of height. -1 when not measured.
|
|
67
|
+
*/
|
|
68
|
+
levelDelta: number
|
|
69
|
+
barDelta: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @experimental */
|
|
73
|
+
export interface ScoredSubmission {
|
|
74
|
+
frameId: number
|
|
75
|
+
ptsUs: number
|
|
76
|
+
intervalUs: number
|
|
77
|
+
submittedNs: number
|
|
78
|
+
/** The audio clock at the submission instant, on the same monotonic timeline. */
|
|
79
|
+
audioUs: number
|
|
80
|
+
/** 0 inside the interval; otherwise the distance to the nearer edge. */
|
|
81
|
+
errorUs: number
|
|
82
|
+
/** Positive when the sound is past the frame's span — the picture is behind. */
|
|
83
|
+
signedUs: number
|
|
84
|
+
occupancy: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @experimental */
|
|
88
|
+
export interface SubmissionReport {
|
|
89
|
+
toleranceUs: number
|
|
90
|
+
scored: number
|
|
91
|
+
/** Submissions with no audio sample close enough to pair with. */
|
|
92
|
+
unpaired: number
|
|
93
|
+
excursions: ScoredSubmission[]
|
|
94
|
+
worstErrorUs: number
|
|
95
|
+
pass: boolean
|
|
96
|
+
/**
|
|
97
|
+
* What this run established, as three outcomes.
|
|
98
|
+
*
|
|
99
|
+
* A score computed from nothing is not a pass. Zero scored submissions means
|
|
100
|
+
* the run did not observe playback, and that has to be distinguishable from
|
|
101
|
+
* a run that observed it and found it clean.
|
|
102
|
+
*/
|
|
103
|
+
verdict: 'pass' | 'fail' | 'invalid'
|
|
104
|
+
verdictReason: string
|
|
105
|
+
/** Gaps between consecutive submitted timestamps: the delivered cadence. */
|
|
106
|
+
cadenceUs: { median: number; min: number; max: number; samples: number }
|
|
107
|
+
/**
|
|
108
|
+
* Wall-clock gaps between consecutive submissions.
|
|
109
|
+
*
|
|
110
|
+
* The tail is the point: a median at the output interval says nothing about
|
|
111
|
+
* the frame that took four times as long, and that frame is the one somebody
|
|
112
|
+
* sees. Taken from `submittedNs`, which is the instant composition began
|
|
113
|
+
* consuming each picture.
|
|
114
|
+
*/
|
|
115
|
+
frameTimeUs: { p50: number; p95: number; p99: number; max: number; samples: number }
|
|
116
|
+
peakOccupancy: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The audio clock at an arbitrary instant, from the paired samples around it.
|
|
121
|
+
*
|
|
122
|
+
* The composition runs at project rate 1x, so the clock is linear between
|
|
123
|
+
* samples and interpolation is exact rather than an approximation. Returns null
|
|
124
|
+
* when there is no sample near enough to interpolate from, which is reported
|
|
125
|
+
* rather than guessed.
|
|
126
|
+
*/
|
|
127
|
+
function audioAt(
|
|
128
|
+
samples: readonly SpikeSample[],
|
|
129
|
+
atNs: number,
|
|
130
|
+
windowNs: number,
|
|
131
|
+
): number | null {
|
|
132
|
+
let before: SpikeSample | null = null
|
|
133
|
+
let after: SpikeSample | null = null
|
|
134
|
+
for (const s of samples) {
|
|
135
|
+
if (!s.playing) continue
|
|
136
|
+
if (s.atNs <= atNs && (before === null || s.atNs > before.atNs)) before = s
|
|
137
|
+
if (s.atNs >= atNs && (after === null || s.atNs < after.atNs)) after = s
|
|
138
|
+
}
|
|
139
|
+
if (before !== null && after !== null && after.atNs > before.atNs) {
|
|
140
|
+
const spanUs = (after.atNs - before.atNs) / 1000
|
|
141
|
+
const movedUs = after.positionUs - before.positionUs
|
|
142
|
+
/*
|
|
143
|
+
Never interpolate across a seek.
|
|
144
|
+
|
|
145
|
+
Between two samples the clock advances with the wall clock, because the
|
|
146
|
+
composition runs at project rate 1x. When it does not, it was *moved* —
|
|
147
|
+
and interpolating across that produced a 2.7 second "error" for a frame
|
|
148
|
+
that was correct when it was handed over: the picture was at 23.9 s and
|
|
149
|
+
the position it was paired with had already jumped back to 21.2 s.
|
|
150
|
+
|
|
151
|
+
Detected from the clock itself rather than from an epoch number, because
|
|
152
|
+
the consumer's frame generation and the player's transition counter are
|
|
153
|
+
different counters and equal values between them mean nothing.
|
|
154
|
+
*/
|
|
155
|
+
if (Math.abs(movedUs - spanUs) <= DISCONTINUITY_US) {
|
|
156
|
+
const t = (atNs - before.atNs) / (after.atNs - before.atNs)
|
|
157
|
+
return before.positionUs + t * movedUs
|
|
158
|
+
}
|
|
159
|
+
// A jump: use the sample on the submission's own side of it.
|
|
160
|
+
return before.positionUs + (atNs - before.atNs) / 1000
|
|
161
|
+
}
|
|
162
|
+
const nearest = before ?? after
|
|
163
|
+
if (nearest === null) return null
|
|
164
|
+
if (Math.abs(nearest.atNs - atNs) > windowNs) return null
|
|
165
|
+
// Rate 1x, so extrapolating the short distance to the edge sample is exact.
|
|
166
|
+
return nearest.positionUs + (atNs - nearest.atNs) / 1000
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How far the clock may diverge from the wall clock and still be running.
|
|
171
|
+
*
|
|
172
|
+
* Beyond this it was moved rather than advanced, which is a seek.
|
|
173
|
+
*/
|
|
174
|
+
const DISCONTINUITY_US = 100_000
|
|
175
|
+
|
|
176
|
+
/** @experimental */
|
|
177
|
+
export function scoreSubmissions(
|
|
178
|
+
submissions: readonly SubmissionRecord[],
|
|
179
|
+
samples: readonly SpikeSample[],
|
|
180
|
+
options: { toleranceUs?: number; pairWindowNs?: number } = {},
|
|
181
|
+
): SubmissionReport {
|
|
182
|
+
const toleranceUs = options.toleranceUs ?? 60_000
|
|
183
|
+
const pairWindowNs = options.pairWindowNs ?? 50_000_000
|
|
184
|
+
|
|
185
|
+
const scored: ScoredSubmission[] = []
|
|
186
|
+
const excursions: ScoredSubmission[] = []
|
|
187
|
+
let unpaired = 0
|
|
188
|
+
let worstErrorUs = 0
|
|
189
|
+
let peakOccupancy = 0
|
|
190
|
+
|
|
191
|
+
for (const record of submissions) {
|
|
192
|
+
if (record.ptsUs < 0 || record.submittedNs <= 0) continue
|
|
193
|
+
if (record.occupancy > peakOccupancy) peakOccupancy = record.occupancy
|
|
194
|
+
const audioUs = audioAt(samples, record.submittedNs, pairWindowNs)
|
|
195
|
+
if (audioUs === null) {
|
|
196
|
+
unpaired += 1
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
/*
|
|
200
|
+
Inside the span is not an error of any size.
|
|
201
|
+
|
|
202
|
+
The frame is the correct picture for the whole interval, so a clock
|
|
203
|
+
anywhere within it is synchronised. Scoring the distance to `ptsUs`
|
|
204
|
+
instead would report the frame's own duration as drift.
|
|
205
|
+
*/
|
|
206
|
+
const start = record.ptsUs
|
|
207
|
+
const end = record.ptsUs + Math.max(record.intervalUs, 0)
|
|
208
|
+
const signedUs = audioUs < start ? audioUs - start : audioUs > end ? audioUs - end : 0
|
|
209
|
+
const errorUs = Math.abs(signedUs)
|
|
210
|
+
const entry: ScoredSubmission = {
|
|
211
|
+
frameId: record.frameId,
|
|
212
|
+
ptsUs: record.ptsUs,
|
|
213
|
+
intervalUs: record.intervalUs,
|
|
214
|
+
submittedNs: record.submittedNs,
|
|
215
|
+
audioUs: Math.round(audioUs),
|
|
216
|
+
errorUs: Math.round(errorUs),
|
|
217
|
+
signedUs: Math.round(signedUs),
|
|
218
|
+
occupancy: record.occupancy,
|
|
219
|
+
}
|
|
220
|
+
scored.push(entry)
|
|
221
|
+
if (errorUs > worstErrorUs) worstErrorUs = errorUs
|
|
222
|
+
if (errorUs > toleranceUs) excursions.push(entry)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const gaps: number[] = []
|
|
226
|
+
const frameTimes: number[] = []
|
|
227
|
+
for (let i = 1; i < scored.length; i += 1) {
|
|
228
|
+
const gap = scored[i]!.ptsUs - scored[i - 1]!.ptsUs
|
|
229
|
+
if (gap > 0) gaps.push(gap)
|
|
230
|
+
const wall = (scored[i]!.submittedNs - scored[i - 1]!.submittedNs) / 1000
|
|
231
|
+
if (wall > 0) frameTimes.push(wall)
|
|
232
|
+
}
|
|
233
|
+
gaps.sort((a, b) => a - b)
|
|
234
|
+
frameTimes.sort((a, b) => a - b)
|
|
235
|
+
const at = (fraction: number) =>
|
|
236
|
+
frameTimes.length === 0
|
|
237
|
+
? 0
|
|
238
|
+
: Math.round(frameTimes[Math.min(frameTimes.length - 1, Math.floor(frameTimes.length * fraction))]!)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
toleranceUs,
|
|
242
|
+
scored: scored.length,
|
|
243
|
+
unpaired,
|
|
244
|
+
excursions,
|
|
245
|
+
worstErrorUs: Math.round(worstErrorUs),
|
|
246
|
+
// No submissions is not a pass: a run that showed nothing was not judged.
|
|
247
|
+
pass: scored.length > 0 && excursions.length === 0,
|
|
248
|
+
verdict: scored.length === 0 ? 'invalid' : excursions.length === 0 ? 'pass' : 'fail',
|
|
249
|
+
verdictReason:
|
|
250
|
+
scored.length === 0
|
|
251
|
+
? 'no submissions scored'
|
|
252
|
+
: excursions.length === 0
|
|
253
|
+
? ''
|
|
254
|
+
: `${excursions.length} excursions, worst ${Math.round(worstErrorUs / 1000)} ms`,
|
|
255
|
+
cadenceUs: {
|
|
256
|
+
median: gaps.length > 0 ? gaps[Math.floor(gaps.length / 2)]! : 0,
|
|
257
|
+
min: gaps.length > 0 ? gaps[0]! : 0,
|
|
258
|
+
max: gaps.length > 0 ? gaps[gaps.length - 1]! : 0,
|
|
259
|
+
samples: gaps.length,
|
|
260
|
+
},
|
|
261
|
+
frameTimeUs: {
|
|
262
|
+
p50: at(0.5),
|
|
263
|
+
p95: at(0.95),
|
|
264
|
+
p99: at(0.99),
|
|
265
|
+
max: frameTimes.length > 0 ? Math.round(frameTimes[frameTimes.length - 1]!) : 0,
|
|
266
|
+
samples: frameTimes.length,
|
|
267
|
+
},
|
|
268
|
+
peakOccupancy,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One submission, with its whole life and the clock it was scored against. @experimental */
|
|
273
|
+
export interface TraceRow {
|
|
274
|
+
frameId: number
|
|
275
|
+
/** Offsets in ms from the window's first row, so the numbers are readable. */
|
|
276
|
+
acquiredMs: number
|
|
277
|
+
dueMs: number
|
|
278
|
+
promotedMs: number
|
|
279
|
+
submittedMs: number
|
|
280
|
+
/**
|
|
281
|
+
* Acquisition to due, due to promotion, promotion to submission, in us.
|
|
282
|
+
*
|
|
283
|
+
* A negative `waitUs` means the presentation time had already passed when the
|
|
284
|
+
* frame was acquired — it was late before the ring saw it.
|
|
285
|
+
*/
|
|
286
|
+
waitUs: number
|
|
287
|
+
promoteUs: number
|
|
288
|
+
submitUs: number
|
|
289
|
+
ptsUs: number
|
|
290
|
+
audioUs: number
|
|
291
|
+
errorUs: number
|
|
292
|
+
slot: number
|
|
293
|
+
occupancy: number
|
|
294
|
+
marker: number
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @experimental */
|
|
298
|
+
export interface SubmissionTrace {
|
|
299
|
+
/** The frame the window is centred on. */
|
|
300
|
+
frameId: number
|
|
301
|
+
rows: TraceRow[]
|
|
302
|
+
/**
|
|
303
|
+
* Where the centre frame's budget went, as the largest single interval.
|
|
304
|
+
*
|
|
305
|
+
* `arrival` when it was acquired late, `due` when it sat waiting for its
|
|
306
|
+
* presentation time, `promotion` when it was due and not chosen, `submission`
|
|
307
|
+
* when promotion and submission diverged. Empty when the row is incomplete.
|
|
308
|
+
*/
|
|
309
|
+
spentOn: 'arrival' | 'due' | 'promotion' | 'submission' | 'unknown'
|
|
310
|
+
/** The dominant leg's size, so a label cannot imply a magnitude it lacks. */
|
|
311
|
+
spentUs: number
|
|
312
|
+
/** True when the window could not be built from complete records. */
|
|
313
|
+
invalid: boolean
|
|
314
|
+
reason: string
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The life of one submission and its neighbours.
|
|
319
|
+
*
|
|
320
|
+
* Built for attribution, not for scoring: a submission excursion says a picture
|
|
321
|
+
* was wrong at an instant, and this says where its time went. Acquisition, due
|
|
322
|
+
* time, promotion and submission are four stamps on one frame, and the largest
|
|
323
|
+
* gap between consecutive ones is the interval to investigate.
|
|
324
|
+
*
|
|
325
|
+
* Incomplete rows are reported rather than dropped. A ring buffer that wrapped,
|
|
326
|
+
* a record written before the ring carried these stamps, or a frame that never
|
|
327
|
+
* reached submission all produce a window that cannot be attributed, and a
|
|
328
|
+
* silently shortened window would read as a clean one.
|
|
329
|
+
*
|
|
330
|
+
* @experimental
|
|
331
|
+
*/
|
|
332
|
+
export function traceSubmission(
|
|
333
|
+
records: readonly SubmissionRecord[],
|
|
334
|
+
scored: readonly ScoredSubmission[],
|
|
335
|
+
frameId: number,
|
|
336
|
+
span = 8,
|
|
337
|
+
): SubmissionTrace {
|
|
338
|
+
const ordered = records.filter((r) => r.submittedNs > 0).sort((a, b) => a.submittedNs - b.submittedNs)
|
|
339
|
+
const centre = ordered.findIndex((r) => r.frameId === frameId)
|
|
340
|
+
if (centre < 0) {
|
|
341
|
+
return {
|
|
342
|
+
frameId,
|
|
343
|
+
rows: [],
|
|
344
|
+
spentOn: 'unknown',
|
|
345
|
+
spentUs: 0,
|
|
346
|
+
invalid: true,
|
|
347
|
+
reason: 'frame not retained',
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const from = Math.max(0, centre - span)
|
|
351
|
+
const window = ordered.slice(from, centre + span + 1)
|
|
352
|
+
const base = window[0]!.submittedNs
|
|
353
|
+
const ms = (ns: number) => (ns > 0 ? Math.round((ns - base) / 1e6) : -1)
|
|
354
|
+
|
|
355
|
+
const rows: TraceRow[] = window.map((r) => {
|
|
356
|
+
const match = scored.find((s) => s.frameId === r.frameId)
|
|
357
|
+
return {
|
|
358
|
+
frameId: r.frameId,
|
|
359
|
+
acquiredMs: ms(r.acquiredNs),
|
|
360
|
+
dueMs: ms(r.dueNs),
|
|
361
|
+
promotedMs: ms(r.promotedNs),
|
|
362
|
+
submittedMs: ms(r.submittedNs),
|
|
363
|
+
waitUs: r.acquiredNs > 0 && r.dueNs > 0 ? Math.round((r.dueNs - r.acquiredNs) / 1000) : -1,
|
|
364
|
+
promoteUs: r.dueNs > 0 && r.promotedNs > 0 ? Math.round((r.promotedNs - r.dueNs) / 1000) : -1,
|
|
365
|
+
submitUs:
|
|
366
|
+
r.promotedNs > 0 && r.submittedNs > 0 ? Math.round((r.submittedNs - r.promotedNs) / 1000) : -1,
|
|
367
|
+
ptsUs: r.ptsUs,
|
|
368
|
+
audioUs: match === undefined ? -1 : match.audioUs,
|
|
369
|
+
errorUs: match === undefined ? -1 : match.errorUs,
|
|
370
|
+
slot: r.slot,
|
|
371
|
+
occupancy: r.occupancy,
|
|
372
|
+
marker: r.submittedMarker,
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const target = rows.find((r) => r.frameId === frameId)!
|
|
377
|
+
/*
|
|
378
|
+
Presence is checked on the raw stamps, not on the offsets.
|
|
379
|
+
|
|
380
|
+
An offset is relative to the window's first submission, so a frame acquired
|
|
381
|
+
before that submission has a negative one — which is ordinary, and which an
|
|
382
|
+
earlier version of this check read as "not recorded" and refused to
|
|
383
|
+
attribute. Zero nanoseconds means never stamped; a negative offset means
|
|
384
|
+
earlier than the window.
|
|
385
|
+
*/
|
|
386
|
+
const raw = window.find((r) => r.frameId === frameId)!
|
|
387
|
+
if (raw.acquiredNs <= 0 || raw.dueNs <= 0 || raw.promotedNs <= 0) {
|
|
388
|
+
return {
|
|
389
|
+
frameId,
|
|
390
|
+
rows,
|
|
391
|
+
spentOn: 'unknown',
|
|
392
|
+
spentUs: 0,
|
|
393
|
+
invalid: true,
|
|
394
|
+
reason: 'incomplete stamps',
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/*
|
|
398
|
+
A frame acquired after its own due time was late before the ring saw it.
|
|
399
|
+
|
|
400
|
+
`waitUs` is due minus acquired, so a negative value means the presentation
|
|
401
|
+
time had already passed on arrival. Measuring promotion from that stale due
|
|
402
|
+
time then charges the ring for the wait as well, and the first version of
|
|
403
|
+
this did exactly that: it reported `promotion` for frames whose promote leg
|
|
404
|
+
was 96 ms and whose wait was minus 52 ms — the same 52 ms counted twice, in
|
|
405
|
+
the wrong place. Promotion is measured from whichever came later.
|
|
406
|
+
*/
|
|
407
|
+
const index = rows.indexOf(target)
|
|
408
|
+
const previous = index > 0 ? rows[index - 1]! : null
|
|
409
|
+
/*
|
|
410
|
+
Arrival is the gap since the previous picture went out, always.
|
|
411
|
+
|
|
412
|
+
An earlier version used the overshoot past the due time instead whenever the
|
|
413
|
+
frame arrived late, and that hid the thing actually worth seeing. Measured
|
|
414
|
+
at the clip seam: the frame missed its due time by 2 ms and had been
|
|
415
|
+
preceded by 133 ms in which nothing was acquired at all. Charging it 2 ms
|
|
416
|
+
named the wrong interval by two orders of magnitude.
|
|
417
|
+
*/
|
|
418
|
+
const arrivalUs =
|
|
419
|
+
previous === null ? 0 : Math.round((target.acquiredMs - previous.submittedMs) * 1000)
|
|
420
|
+
const promoteUs = target.waitUs < 0 ? target.promoteUs + target.waitUs : target.promoteUs
|
|
421
|
+
const legs: [SubmissionTrace['spentOn'], number][] = [
|
|
422
|
+
['arrival', arrivalUs],
|
|
423
|
+
['due', Math.max(0, target.waitUs)],
|
|
424
|
+
['promotion', promoteUs],
|
|
425
|
+
['submission', target.submitUs],
|
|
426
|
+
]
|
|
427
|
+
let spentOn: SubmissionTrace['spentOn'] = 'unknown'
|
|
428
|
+
let worst = -1
|
|
429
|
+
for (const [name, value] of legs) {
|
|
430
|
+
if (value > worst) {
|
|
431
|
+
worst = value
|
|
432
|
+
spentOn = name
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { frameId, rows, spentOn, spentUs: Math.max(0, worst), invalid: false, reason: '' }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** @experimental */
|
|
439
|
+
export interface SeamStage {
|
|
440
|
+
framePtsUs: number
|
|
441
|
+
/** Offsets in ms from the window's first sample. */
|
|
442
|
+
metadataMs: number
|
|
443
|
+
availableMs: number
|
|
444
|
+
consumedMs: number
|
|
445
|
+
/** Renderer to queue, queue to latch, in us. -1 when a stamp is missing. */
|
|
446
|
+
graphUs: number
|
|
447
|
+
latchUs: number
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @experimental */
|
|
451
|
+
export interface SeamTrace {
|
|
452
|
+
/** The boundary this window is centred on, in project time. */
|
|
453
|
+
atUs: number
|
|
454
|
+
/** The largest gap between consecutive frames reaching the renderer, in us. */
|
|
455
|
+
outputGapUs: number
|
|
456
|
+
/** The same gap measured at acquisition. */
|
|
457
|
+
acquireGapUs: number
|
|
458
|
+
/**
|
|
459
|
+
* Who owns the gap.
|
|
460
|
+
*
|
|
461
|
+
* `graph` when the renderer produced and the queue did not follow.
|
|
462
|
+
* `consumer` when it was queued and not latched. `production` when nothing
|
|
463
|
+
* reached the renderer's output callback at all.
|
|
464
|
+
*
|
|
465
|
+
* That last one is deliberately broader than "the decoder was unprepared":
|
|
466
|
+
* this stamp fires when the renderer is about to hand a frame to the surface,
|
|
467
|
+
* so its absence covers a decoder that produced nothing *and* a renderer
|
|
468
|
+
* blocked waiting for the sink to accept a new input stream. Separating those
|
|
469
|
+
* two needs a stamp inside Media3 that is not exposed here. What it does
|
|
470
|
+
* establish is that the graph and this consumer are not responsible, which is
|
|
471
|
+
* what the acquisition gap alone could not say.
|
|
472
|
+
*/
|
|
473
|
+
owner: 'production' | 'graph' | 'consumer' | 'unknown'
|
|
474
|
+
stages: SeamStage[]
|
|
475
|
+
invalid: boolean
|
|
476
|
+
reason: string
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Where output stops at a clip boundary.
|
|
481
|
+
*
|
|
482
|
+
* The acquisition gap says delivery stopped; it cannot say whether the decoder
|
|
483
|
+
* produced nothing, the video graph held what it produced, or this consumer
|
|
484
|
+
* failed to latch what was queued. Those are three owners and three different
|
|
485
|
+
* fixes, and they are separated by one stamp: when the renderer said a frame
|
|
486
|
+
* was about to go to the surface.
|
|
487
|
+
*
|
|
488
|
+
* Samples carrying no frame are skipped rather than treated as a gap — the
|
|
489
|
+
* poller runs faster than the frame rate, so most samples repeat the last
|
|
490
|
+
* frame, and counting those would report a stall on every tick.
|
|
491
|
+
*
|
|
492
|
+
* @experimental
|
|
493
|
+
*/
|
|
494
|
+
export function traceSeam(
|
|
495
|
+
samples: readonly SpikeSample[],
|
|
496
|
+
atUs: number,
|
|
497
|
+
spanUs = 600_000,
|
|
498
|
+
): SeamTrace {
|
|
499
|
+
const near = samples
|
|
500
|
+
.filter(
|
|
501
|
+
(s) =>
|
|
502
|
+
s.framePtsUs >= 0 &&
|
|
503
|
+
s.framePtsUs >= atUs - spanUs &&
|
|
504
|
+
s.framePtsUs <= atUs + spanUs &&
|
|
505
|
+
s.frameConsumedNs > 0,
|
|
506
|
+
)
|
|
507
|
+
.sort((a, b) => a.frameConsumedNs - b.frameConsumedNs)
|
|
508
|
+
|
|
509
|
+
// One row per distinct frame: the poller ticks faster than frames arrive.
|
|
510
|
+
const unique: SpikeSample[] = []
|
|
511
|
+
for (const s of near) {
|
|
512
|
+
if (unique.length === 0 || unique[unique.length - 1]!.frameId !== s.frameId) unique.push(s)
|
|
513
|
+
}
|
|
514
|
+
if (unique.length < 3) {
|
|
515
|
+
return {
|
|
516
|
+
atUs,
|
|
517
|
+
outputGapUs: -1,
|
|
518
|
+
acquireGapUs: -1,
|
|
519
|
+
owner: 'unknown',
|
|
520
|
+
stages: [],
|
|
521
|
+
invalid: true,
|
|
522
|
+
reason: 'too few frames around the boundary',
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const base = unique[0]!.frameConsumedNs
|
|
527
|
+
const ms = (ns: number) => (ns > 0 ? Math.round((ns - base) / 1e6) : -1)
|
|
528
|
+
const stages: SeamStage[] = unique.map((s) => ({
|
|
529
|
+
framePtsUs: s.framePtsUs,
|
|
530
|
+
metadataMs: ms(s.frameMetadataNs),
|
|
531
|
+
availableMs: ms(s.frameAvailableNs),
|
|
532
|
+
consumedMs: ms(s.frameConsumedNs),
|
|
533
|
+
graphUs:
|
|
534
|
+
s.frameMetadataNs > 0 && s.frameAvailableNs > 0
|
|
535
|
+
? Math.round((s.frameAvailableNs - s.frameMetadataNs) / 1000)
|
|
536
|
+
: -1,
|
|
537
|
+
latchUs:
|
|
538
|
+
s.frameAvailableNs > 0 && s.frameConsumedNs > 0
|
|
539
|
+
? Math.round((s.frameConsumedNs - s.frameAvailableNs) / 1000)
|
|
540
|
+
: -1,
|
|
541
|
+
}))
|
|
542
|
+
|
|
543
|
+
let outputGapUs = 0
|
|
544
|
+
let acquireGapUs = 0
|
|
545
|
+
let at = -1
|
|
546
|
+
for (let i = 1; i < unique.length; i += 1) {
|
|
547
|
+
const acquire = Math.round((unique[i]!.frameConsumedNs - unique[i - 1]!.frameConsumedNs) / 1000)
|
|
548
|
+
if (acquire > acquireGapUs) {
|
|
549
|
+
acquireGapUs = acquire
|
|
550
|
+
at = i
|
|
551
|
+
}
|
|
552
|
+
const a = unique[i]!.frameMetadataNs
|
|
553
|
+
const b = unique[i - 1]!.frameMetadataNs
|
|
554
|
+
if (a > 0 && b > 0) outputGapUs = Math.max(outputGapUs, Math.round((a - b) / 1000))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/*
|
|
558
|
+
The owner is read off the widest frame's own stages, not off the gap.
|
|
559
|
+
|
|
560
|
+
A gap is a distance between two frames; the question is where the later one
|
|
561
|
+
was held, and that is inside its own row.
|
|
562
|
+
*/
|
|
563
|
+
let owner: SeamTrace['owner'] = 'unknown'
|
|
564
|
+
if (at > 0) {
|
|
565
|
+
const s = stages[at]!
|
|
566
|
+
if (s.graphUs < 0 || s.latchUs < 0) owner = 'unknown'
|
|
567
|
+
else if (s.graphUs >= s.latchUs && s.graphUs > 16_000) owner = 'graph'
|
|
568
|
+
else if (s.latchUs > 16_000) owner = 'consumer'
|
|
569
|
+
else owner = 'production'
|
|
570
|
+
}
|
|
571
|
+
return { atUs, outputGapUs, acquireGapUs, owner, stages, invalid: false, reason: '' }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** One frame, seen from both sides of the pairing. @experimental */
|
|
575
|
+
export interface StartupRow {
|
|
576
|
+
frameId: number
|
|
577
|
+
/** What Media3 said this frame's presentation time was. */
|
|
578
|
+
renderedPtsUs: number
|
|
579
|
+
/** What the submission record was written under. The same number, or a bug. */
|
|
580
|
+
recordedPtsUs: number
|
|
581
|
+
/** What the pixels say the frame is. Independent of both. */
|
|
582
|
+
marker: number
|
|
583
|
+
/** The source frame the marker's own index implies, at this clip's speed. */
|
|
584
|
+
markerPtsUs: number
|
|
585
|
+
metadataMs: number
|
|
586
|
+
consumedMs: number
|
|
587
|
+
submittedMs: number
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** @experimental */
|
|
591
|
+
export interface StartupTrace {
|
|
592
|
+
rows: StartupRow[]
|
|
593
|
+
/**
|
|
594
|
+
* Frames the renderer never emitted, by source index.
|
|
595
|
+
*
|
|
596
|
+
* The discriminator. If the renderer emitted a presentation time and the
|
|
597
|
+
* pixels under it belong to a different frame, the *association* is wrong. If
|
|
598
|
+
* it never emitted that time at all, the frame was dropped or never produced.
|
|
599
|
+
* Those are different defects and the pixels alone cannot tell them apart.
|
|
600
|
+
*/
|
|
601
|
+
missingSourceFrames: number[]
|
|
602
|
+
/** Frames whose pixels disagree with the time they were rendered under. */
|
|
603
|
+
misassociated: number[]
|
|
604
|
+
invalid: boolean
|
|
605
|
+
reason: string
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The first frames of a clip, from both sides of the pairing at once.
|
|
610
|
+
*
|
|
611
|
+
* Media3's presentation time and the marker in the pixels are independent
|
|
612
|
+
* claims about the same frame. Joined by frame id, they separate "frames 2 to 6
|
|
613
|
+
* were never produced" from "frame 7's pixels arrived labelled as frame 2" —
|
|
614
|
+
* which look identical in a cadence result and need opposite fixes.
|
|
615
|
+
*
|
|
616
|
+
* @experimental
|
|
617
|
+
*/
|
|
618
|
+
export function traceStartup(
|
|
619
|
+
samples: readonly SpikeSample[],
|
|
620
|
+
records: readonly SubmissionRecord[],
|
|
621
|
+
options: { sourceFps: number; speed: number; count?: number },
|
|
622
|
+
): StartupTrace {
|
|
623
|
+
const count = options.count ?? 10
|
|
624
|
+
if (options.sourceFps <= 0 || options.speed <= 0) {
|
|
625
|
+
return { rows: [], missingSourceFrames: [], misassociated: [], invalid: true, reason: 'no clip rate' }
|
|
626
|
+
}
|
|
627
|
+
// One source frame in project time: the unit both sides are compared in.
|
|
628
|
+
const stepUs = 1_000_000 / options.sourceFps / options.speed
|
|
629
|
+
|
|
630
|
+
const byFrame = new Map<number, SpikeSample>()
|
|
631
|
+
for (const s of samples) {
|
|
632
|
+
if (s.frameId < 0 || s.frameConsumedNs <= 0) continue
|
|
633
|
+
const seen = byFrame.get(s.frameId)
|
|
634
|
+
if (seen === undefined || s.frameConsumedNs < seen.frameConsumedNs) byFrame.set(s.frameId, s)
|
|
635
|
+
}
|
|
636
|
+
const ordered = records
|
|
637
|
+
.filter((r) => r.submittedNs > 0)
|
|
638
|
+
.sort((a, b) => a.submittedNs - b.submittedNs)
|
|
639
|
+
.slice(0, count)
|
|
640
|
+
if (ordered.length === 0) {
|
|
641
|
+
return { rows: [], missingSourceFrames: [], misassociated: [], invalid: true, reason: 'no submissions' }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const base = ordered[0]!.submittedNs
|
|
645
|
+
const ms = (ns: number) => (ns > 0 ? Math.round((ns - base) / 1e6) : -1)
|
|
646
|
+
const rows: StartupRow[] = ordered.map((r) => {
|
|
647
|
+
const s = byFrame.get(r.frameId)
|
|
648
|
+
return {
|
|
649
|
+
frameId: r.frameId,
|
|
650
|
+
renderedPtsUs: s === undefined ? -1 : s.framePtsUs,
|
|
651
|
+
recordedPtsUs: r.ptsUs,
|
|
652
|
+
marker: r.submittedMarker,
|
|
653
|
+
markerPtsUs: r.submittedMarker >= 0 ? Math.round(r.submittedMarker * stepUs) : -1,
|
|
654
|
+
metadataMs: s === undefined ? -1 : ms(s.frameMetadataNs),
|
|
655
|
+
consumedMs: s === undefined ? -1 : ms(s.frameConsumedNs),
|
|
656
|
+
submittedMs: ms(r.submittedNs),
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
/*
|
|
661
|
+
Misassociated when the pixels and the rendered time name different frames;
|
|
662
|
+
missing when the rule's frame never appears as a rendered time at all.
|
|
663
|
+
*/
|
|
664
|
+
const misassociated: number[] = []
|
|
665
|
+
const rendered = new Set<number>()
|
|
666
|
+
for (const row of rows) {
|
|
667
|
+
if (row.renderedPtsUs >= 0) rendered.add(Math.round(row.renderedPtsUs / stepUs))
|
|
668
|
+
if (row.marker < 0 || row.renderedPtsUs < 0) continue
|
|
669
|
+
if (Math.abs(row.markerPtsUs - row.renderedPtsUs) > stepUs / 2) misassociated.push(row.frameId)
|
|
670
|
+
}
|
|
671
|
+
const last = rows[rows.length - 1]!
|
|
672
|
+
const highest = Math.max(
|
|
673
|
+
last.marker >= 0 ? last.marker : 0,
|
|
674
|
+
last.renderedPtsUs >= 0 ? Math.round(last.renderedPtsUs / stepUs) : 0,
|
|
675
|
+
)
|
|
676
|
+
const missingSourceFrames: number[] = []
|
|
677
|
+
for (let i = 0; i <= highest; i += 1) if (!rendered.has(i)) missingSourceFrames.push(i)
|
|
678
|
+
|
|
679
|
+
return { rows, missingSourceFrames, misassociated, invalid: false, reason: '' }
|
|
680
|
+
}
|