@pictor-sdk/video-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,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
+ }