@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,950 @@
1
+ import { resampleClip, type ResampleRequest } from '@pictor-sdk/video-core'
2
+ import type { SpikeSample } from './compositionSpike'
3
+ import type { SubmissionRecord } from './submissionScoring'
4
+
5
+ /**
6
+ * Did the preview show the right frame at each output instant?
7
+ *
8
+ * ## The oracle this replaces
9
+ *
10
+ * The first version counted *source* frames and expected every one to appear.
11
+ * That reported a 2x clip — 6 seconds of source occupying 3 seconds — as
12
+ * delivering 98 of 180 frames with 58 missed intervals. The 180 was wrong: a
13
+ * preview has its own output rate, and at 30 fps three seconds of timeline has
14
+ * about 90 output instants, at each of which the resampling rule *skips* every
15
+ * second source frame by design. Requiring all 180 asked the preview to run at
16
+ * 60 fps and then scored it for refusing.
17
+ *
18
+ * So the oracle is the shared rule itself. `resampleClip` already decides which
19
+ * source frame belongs at each output instant, at a stated target rate, and it
20
+ * is the same function the exporter's contract is written against. Using it
21
+ * here means the preview and the export cannot disagree about what "correct"
22
+ * means.
23
+ *
24
+ * ## What is checked
25
+ *
26
+ * At each expected output instant, whether the picture on screen is the frame
27
+ * the rule names. Not whether every source frame appeared — above 1x most of
28
+ * them must not.
29
+ *
30
+ * @experimental
31
+ */
32
+
33
+ /** One clip, as the playlist compiled it. @experimental */
34
+ export interface CadenceClip {
35
+ name: string
36
+ /** Where the window sits on the project timeline. */
37
+ timelineStartUs: number
38
+ timelineEndUs: number
39
+ /** Clipping bounds in source time. */
40
+ sourceStartUs: number
41
+ sourceEndUs: number
42
+ speed: number
43
+ sourceFps: number
44
+ }
45
+
46
+ /**
47
+ * How far the preview may sit from the export's frame selection.
48
+ *
49
+ * The tolerance the rest of the video path is held to. A preview picture is
50
+ * temporally accurate when the frame it shows is within this of the frame the
51
+ * rule names, converted to project time — not when it is that exact frame.
52
+ *
53
+ * @experimental
54
+ */
55
+ export const PREVIEW_TOLERANCE_US = 60_000
56
+
57
+ /**
58
+ * How far a correctly rendered picture may be from its own frame's content.
59
+ *
60
+ * The level tolerance absorbs the codec: a flat grey through H.264 at 4:2:0 and
61
+ * back through a scaler does not return the exact value it went in as. The bar
62
+ * tolerance is a little over one source row at the fixture's height, which is
63
+ * what an interpolating scaler can move an edge by. Neither absorbs a flip, an
64
+ * offset or a wrong scale, which move the bar by whole per cents.
65
+ */
66
+ const PARITY_LEVEL_TOLERANCE = 12
67
+ const PARITY_BAR_TOLERANCE = 4 / 960
68
+
69
+ /**
70
+ * What a run established, as three outcomes rather than two.
71
+ *
72
+ * `invalid` is not a soft failure — it is the absence of a measurement, and it
73
+ * must never be reachable by a run that simply had the probe switched off. A
74
+ * boolean cannot say that: `false` reads as "measured and wrong", and three
75
+ * device runs once reported `wrong=0, worstOffset=0.0 ms` because a shifted
76
+ * button meant nothing was scorable at all. That was a clean pass by absence of
77
+ * evidence and one paste from being reported as a fix working.
78
+ *
79
+ * @experimental
80
+ */
81
+ export type Verdict = 'pass' | 'fail' | 'invalid'
82
+
83
+ /** Rendering correctness, per clip. @experimental */
84
+ export interface CadenceParity {
85
+ /** Submissions that carried a parity measurement. */
86
+ measured: number
87
+ /** Worst background-level error, 0..255. */
88
+ worstLevelDelta: number
89
+ /** Worst bar-position error, in 0..1 of height. */
90
+ worstBarDelta: number
91
+ /** Submissions whose rendering was outside tolerance. */
92
+ failures: number
93
+ pass: boolean
94
+ }
95
+
96
+ /** @experimental */
97
+ export interface CadenceResult {
98
+ clip: string
99
+ /** Output instants the rule names for the whole clip. */
100
+ nominalInstants: number
101
+ /** Output instants the rule expects across the span actually covered. */
102
+ expectedInstants: number
103
+ /**
104
+ * Instants that could not be placed on the wall clock.
105
+ *
106
+ * No bracketing pair of player-clock samples inside one segment — the
107
+ * transport was paused or seeking across them. Counted and excluded from
108
+ * scoring rather than placed by extrapolation.
109
+ */
110
+ unplaceableInstants: number
111
+ /**
112
+ * How far the frame-derived clock sits from the player's, in microseconds.
113
+ *
114
+ * A diagnostic, never the anchor. Positive means pictures are handed over
115
+ * later than the project time they carry — the pipeline's systematic
116
+ * presentation delay.
117
+ *
118
+ * Anchoring on the frame-derived value would subtract this from every
119
+ * measurement and call a uniformly late pipeline punctual, which is why
120
+ * instants are placed from the player's clock and this is only reported.
121
+ */
122
+ frameDerivedOffsetUs: number
123
+ /**
124
+ * Instants before the first submission, and after the last.
125
+ *
126
+ * Named rather than absorbed. Scoring a clip over its nominal length would
127
+ * count every instant a run never reached as a missed deadline; excluding
128
+ * them silently would let a picture that arrived late or stopped early
129
+ * disappear from coverage instead. They are excluded from the identity check
130
+ * and reported here, and `leadingExcluded` in particular is the interval
131
+ * where the previous clip was still on screen — which is the seam, and is
132
+ * scored by the submission metric rather than by this one.
133
+ */
134
+ leadingExcluded: number
135
+ trailingExcluded: number
136
+ /** Instants where some frame was on screen. */
137
+ coveredInstants: number
138
+ /**
139
+ * Instants showing a different source frame from the one the rule names.
140
+ *
141
+ * The real failure: a missed deadline is the picture being wrong at a moment
142
+ * somebody was looking, not a source frame going unused.
143
+ */
144
+ wrongFrameInstants: number
145
+ /** Instants with no frame at all — the picture had not caught up. */
146
+ uncoveredInstants: number
147
+ /** Unique pictures submitted inside the span. */
148
+ submittedUnique: number
149
+ /**
150
+ * Source frames the rule says to skip.
151
+ *
152
+ * Above 1x this is most of them and is correct. Reported so a count of
153
+ * "missing" frames cannot be read as loss.
154
+ */
155
+ ruleSkippedSourceFrames: number
156
+ /** The interval between output instants, from the declared preview rate. */
157
+ outputIntervalUs: number
158
+ medianHoldUs: number
159
+ maxHoldUs: number
160
+ /**
161
+ * How long one picture should stay up, from the rule.
162
+ *
163
+ * At 2x a new source frame is due at every output instant; at 0.5x the rule
164
+ * repeats each one, so a hold of two instants is correct rather than a stall.
165
+ */
166
+ expectedHoldUs: number
167
+ /**
168
+ * Pictures held past their turn.
169
+ *
170
+ * Counted from the holds rather than from the covered span, because a stall
171
+ * at the end of a run produces no further submissions and would otherwise
172
+ * simply shorten the span it was scored over.
173
+ */
174
+ missedDeadlines: number
175
+ /**
176
+ * How far off the shown frame was, counted in source frames.
177
+ *
178
+ * Keyed by `shown - expected`, over every scored instant whose identity was
179
+ * established — the correct ones included, so the shape of the whole run is
180
+ * visible rather than only its tail. This is what separates a picture that is
181
+ * one source frame early from one that is a third of a second stale, and at
182
+ * 2x those are 8.3 ms and 330 ms of project time: the same count of "wrong
183
+ * instants" and completely different defects.
184
+ */
185
+ offsets: Record<string, number>
186
+ /**
187
+ * The largest offset, in project time rather than in source frames.
188
+ *
189
+ * The conversion that makes the count mean something. One source frame at
190
+ * speed `s` is `1 / sourceFps / s` of project time — 16.7 ms at 2x, 66.7 ms
191
+ * at 0.5x — so the same offset of three frames is 50 ms in one clip and 200
192
+ * ms in another. Judged against the synchronisation tolerance, not against
193
+ * zero: a preview consumes a live decoder at its own display rate and cannot
194
+ * seek to an exact source frame per instant the way an export does.
195
+ */
196
+ worstOffsetUs: number
197
+ /**
198
+ * Rendering correctness, against the frame each picture's own band names.
199
+ *
200
+ * Separate from everything above it. The rest of this result is about *which*
201
+ * frame was shown; this is about whether that frame was drawn correctly —
202
+ * colour, orientation and scale. A picture one source frame early and
203
+ * rendered perfectly scores zero here.
204
+ */
205
+ parity: CadenceParity
206
+ /**
207
+ * Instants that showed exactly the frame the export rule names.
208
+ *
209
+ * Reported, never required. It is the export contract's measure, kept here so
210
+ * the preview's tolerance is visible as a decision rather than as the absence
211
+ * of a check.
212
+ */
213
+ exactInstants: number
214
+ /**
215
+ * What this run established. @see Verdict
216
+ *
217
+ * `pass` only when identity was read from pixels, parity was measured, and
218
+ * both were clean. Anything unmeasured is `invalid`.
219
+ */
220
+ verdict: Verdict
221
+ /** Why, when the verdict is not a pass. */
222
+ verdictReason: string
223
+ /** Whether the acquired-identity evidence was usable at all. */
224
+ identity: CadenceIdentity
225
+ /**
226
+ * Wall-clock gaps between this clip's submissions.
227
+ *
228
+ * Per clip because the aggregate mixes rates: a 0.5x clip legitimately holds
229
+ * each picture for two output intervals, so a global p95 at 66.7 ms is the
230
+ * 0.5x clip behaving correctly and says nothing about whether the 2x clip
231
+ * stalled. Reported separately so a delay cannot hide inside a hold.
232
+ */
233
+ frameTimeUs: { p50: number; p95: number; p99: number; max: number; samples: number }
234
+ /**
235
+ * Every instant whose picture was not the one the rule names.
236
+ *
237
+ * Including the boundary instants the scored span excludes: those are the
238
+ * seam, and leaving them out of the identity check is how a wrong picture at
239
+ * a clip change disappears from both metrics at once.
240
+ */
241
+ wrong: WrongInstant[]
242
+ pass: boolean
243
+ }
244
+
245
+ /**
246
+ * Why an instant showed the wrong picture.
247
+ *
248
+ * The first three are decided in pixels and name one place to look. The fourth
249
+ * is what the metadata alone can support, which is less than it appears: on
250
+ * this device `SurfaceTexture` reports a system-clock value, so an instant
251
+ * classified without the marker cannot separate a frame acquired wrongly from a
252
+ * frame chosen wrongly, and says so rather than picking one.
253
+ *
254
+ * @experimental
255
+ */
256
+ export type WrongInstantCause =
257
+ /**
258
+ * The expected frame's pixels were not in the ring when the instant needed
259
+ * them.
260
+ *
261
+ * Nothing downstream could have shown it. The fault is decode, acquisition or
262
+ * the copy into the ring — and the test is by that instant, not by the end of
263
+ * the clip, because every frame arrives eventually and a clip-global check
264
+ * would charge a frame that had simply not arrived yet to selection.
265
+ */
266
+ | 'not-delivered'
267
+ /**
268
+ * The expected frame's pixels were in the ring, and a different frame was
269
+ * submitted.
270
+ *
271
+ * The fault is the choice, not the supply.
272
+ */
273
+ | 'wrong-selection'
274
+ /**
275
+ * The submitted picture no longer held the frame its slot was filled with.
276
+ *
277
+ * A wrapped texture aliases storage the ring may redraw. This is that storage
278
+ * changing under a picture composition still holds, and it is the one cause
279
+ * here that is a correctness defect in the ring's own contract.
280
+ */
281
+ | 'storage-aliased'
282
+ /**
283
+ * The wrong picture, and the evidence cannot say whether it was acquired or
284
+ * chosen wrongly.
285
+ *
286
+ * What the metadata supports with the probe off. Not a third fault: a
287
+ * placeholder for an attribution not yet made.
288
+ */
289
+ | 'wrong-submitted-identity'
290
+ /**
291
+ * The right frame, submitted after the instant it belonged to.
292
+ *
293
+ * Only reachable without pixel identity. When the marker is readable, a frame
294
+ * matching the rule makes the instant correct however late it was submitted,
295
+ * so lateness stops being an identity fault and becomes a frame-time one.
296
+ * Its evidence — the ring's own submission time and project timestamp — is
297
+ * independent of the disputed pairing, which is why it survives there.
298
+ */
299
+ | 'late-delivery'
300
+ /** No submission covered the instant at all. */
301
+ | 'uncovered'
302
+
303
+ /** @experimental */
304
+ export interface WrongInstant {
305
+ projectUs: number
306
+ /** From the shared rule: the source frame that belongs at this instant. */
307
+ expectedSourceFrame: number
308
+ expectedSourcePtsUs: number
309
+ /** What was on screen, mapped back through the same speed. */
310
+ shownSourceFrame: number
311
+ shownPtsUs: number
312
+ /** What the texture said it held when that frame was acquired. */
313
+ acquiredTexturePtsUs: number
314
+ /** The frame index in the pixels of the slot, and of the submitted picture. */
315
+ acquiredMarker: number
316
+ submittedMarker: number
317
+ slot: number
318
+ frameId: number
319
+ submittedNs: number
320
+ cause: WrongInstantCause
321
+ /** True when the instant lies outside the scored span. */
322
+ boundary: boolean
323
+ }
324
+
325
+ /** What the texture timestamp turned out to be. @experimental */
326
+ export type TimestampDomain =
327
+ /** Comparable with the clip's media time. */
328
+ | 'media'
329
+ /** A system-clock value: `System.nanoTime()`, as measured on this device. */
330
+ | 'system'
331
+ /** Nothing was recorded, so there is nothing to establish. */
332
+ | 'unknown'
333
+
334
+ /** @experimental */
335
+ export interface CadenceIdentity {
336
+ /**
337
+ * The domain the texture timestamps were established to be in.
338
+ *
339
+ * Recorded rather than assumed, and recorded even when it is the one that
340
+ * makes the check weaker. It was `media` by assumption for one whole run and
341
+ * the assumption was wrong by forty-eight hours.
342
+ */
343
+ timestampDomain: TimestampDomain
344
+ medianTextureSkewUs: number
345
+ /**
346
+ * Whether identity came from the pixels.
347
+ *
348
+ * When false, the fallback compares submitted timestamps — which on a
349
+ * `system` domain cannot uniquely associate a buffer with a frame. Every
350
+ * instant is then counted `identityUnverified` and the clip cannot pass. A
351
+ * fallback that quietly declared those instants correct would be restoring
352
+ * confidence the evidence does not support.
353
+ */
354
+ pixelIdentity: boolean
355
+ /** Instants whose identity could not be established either way. */
356
+ identityUnverified: number
357
+ /** Distinct frame indices read out of the ring for this clip. */
358
+ deliveredFrames: number
359
+ /** Bands that were read, and bands that failed. */
360
+ markersRead: number
361
+ markersFailed: number
362
+ }
363
+
364
+ /**
365
+ * The preview's own output rate.
366
+ *
367
+ * Stated rather than inferred from what arrived: an oracle that takes its
368
+ * expectation from the measurement cannot fail.
369
+ *
370
+ * @experimental
371
+ */
372
+ export const PREVIEW_OUTPUT_FPS = 30
373
+
374
+ /**
375
+ * Which source frame was on screen at `projectUs`, from what was submitted.
376
+ *
377
+ * The last submission at or before the instant — the same "last one at or
378
+ * before" rule `resampleClip` samples the source with, because a nearest-match
379
+ * would credit a frame that had not been shown yet.
380
+ */
381
+ /**
382
+ * When an instant of project time happened, on the monotonic clock.
383
+ *
384
+ * Built from the player's own reported position paired with the monotonic time
385
+ * it was read at — two values the pump records together and neither derived
386
+ * from a frame. That independence is the point: an offset computed from
387
+ * submission records folds any systematic presentation delay into the anchor,
388
+ * so a pipeline uniformly late looks punctual to itself.
389
+ *
390
+ * Segmented by epoch and by whether the transport was running. A pause or a
391
+ * seek breaks the correspondence between project time and the wall clock
392
+ * entirely, and interpolating across one would place an instant at a moment
393
+ * that never occurred. Instants with no bracketing pair inside a single segment
394
+ * return null rather than a guess.
395
+ */
396
+ function wallClockAt(
397
+ samples: readonly SpikeSample[],
398
+ projectUs: number,
399
+ intervalUs: number,
400
+ ): number | null {
401
+ let before: SpikeSample | null = null
402
+ let after: SpikeSample | null = null
403
+ for (const s of samples) {
404
+ if (!s.playing || s.positionUs < 0 || s.atNs <= 0) continue
405
+ if (s.positionUs <= projectUs && (before === null || s.positionUs > before.positionUs)) before = s
406
+ if (s.positionUs >= projectUs && (after === null || s.positionUs < after.positionUs)) after = s
407
+ }
408
+ /*
409
+ One interval past the last sample, and no further.
410
+
411
+ The final instant of a clip has nothing after it to bracket with, and
412
+ refusing it would make every real run end in an unplaceable instant and
413
+ report `invalid`. Extrapolating one interval at the project rate is exact
414
+ enough for a span that short; beyond it the transport may have stopped and
415
+ there is no evidence either way.
416
+ */
417
+ if (after === null) {
418
+ if (before === null || projectUs - before.positionUs > intervalUs) return null
419
+ return before.atNs + (projectUs - before.positionUs) * 1000
420
+ }
421
+ /*
422
+ And one interval before the first sample, symmetrically.
423
+
424
+ An instant can land just ahead of the earliest reading — the output grid and
425
+ the poller are not aligned, and the grid's rounding puts one instant of a
426
+ clip on the wrong side of it. The same bound applies: inside an interval the
427
+ project rate places it exactly, beyond it there is no evidence.
428
+ */
429
+ if (before === null) {
430
+ if (after.positionUs - projectUs > intervalUs) return null
431
+ return after.atNs - (after.positionUs - projectUs) * 1000
432
+ }
433
+ // Different segments: a pause or a seek sits between them.
434
+ if (before.epoch !== after.epoch) return null
435
+ if (after.positionUs === before.positionUs) return before.atNs
436
+ const t = (projectUs - before.positionUs) / (after.positionUs - before.positionUs)
437
+ return before.atNs + t * (after.atNs - before.atNs)
438
+ }
439
+
440
+ /**
441
+ * The picture on screen at an instant — as of that instant.
442
+ *
443
+ * Selected by when it was submitted, not only by what time it claims. Choosing
444
+ * the greatest `ptsUs` at or before the instant let a frame submitted long
445
+ * afterwards be credited to it: the screen showed something older at the time,
446
+ * and the late arrival made the instant correct retroactively. A frame that had
447
+ * not been handed to composition yet was not on screen, whatever its timestamp
448
+ * says.
449
+ *
450
+ * The instant's wall-clock time is derived from the clip's first submission and
451
+ * the project rate, which is 1x for the composition — the same assumption the
452
+ * audio pairing documents and relies on.
453
+ */
454
+ function displayedAt(
455
+ submissions: readonly SubmissionRecord[],
456
+ projectUs: number,
457
+ atNs: number | null,
458
+ intervalNs: number,
459
+ ): SubmissionRecord | null {
460
+ /*
461
+ One output interval of slack, because an instant is an interval.
462
+
463
+ An output instant is not a point in time — it is the span during which its
464
+ picture should be on screen. A frame handed over anywhere inside that span
465
+ was shown for part of it, and excluding it would charge the grid's own
466
+ resolution to the pipeline. A frame arriving after the span has closed was
467
+ never on screen for it, which is the case this rule exists to exclude.
468
+ */
469
+ if (atNs === null) return null
470
+ const deadlineNs = atNs + intervalNs
471
+ let best: SubmissionRecord | null = null
472
+ for (const s of submissions) {
473
+ if (s.ptsUs > projectUs) continue
474
+ // Not yet submitted when the instant passed: it was not the picture.
475
+ if (s.submittedNs > deadlineNs) continue
476
+ if (best === null || s.submittedNs > best.submittedNs) best = s
477
+ }
478
+ return best
479
+ }
480
+
481
+ /** @experimental */
482
+ export function checkCadence(
483
+ clip: CadenceClip,
484
+ submissions: readonly SubmissionRecord[],
485
+ outputFps = PREVIEW_OUTPUT_FPS,
486
+ /**
487
+ * How far the shown frame may be from the named one, in project time.
488
+ *
489
+ * Zero scores the export contract — exact selection, no tolerance — and the
490
+ * default scores the preview's. @see PREVIEW_TOLERANCE_US
491
+ */
492
+ toleranceUs = PREVIEW_TOLERANCE_US,
493
+ /**
494
+ * The player's own clock, paired with monotonic time.
495
+ *
496
+ * Where instants are placed on the wall clock. Empty means no instant can be
497
+ * placed, and the result is `invalid` rather than scored against a guess.
498
+ */
499
+ samples: readonly SpikeSample[] = [],
500
+ ): CadenceResult {
501
+ const request: ResampleRequest = {
502
+ sourceStartUs: clip.sourceStartUs as never,
503
+ sourceEndUs: clip.sourceEndUs as never,
504
+ speed: clip.speed,
505
+ sourceFps: clip.sourceFps,
506
+ targetFps: outputFps,
507
+ }
508
+ const plan = resampleClip(request)
509
+ const outputIntervalUs = Math.round(1_000_000 / outputFps)
510
+
511
+ const inClip = submissions
512
+ .filter((s) => s.ptsUs >= clip.timelineStartUs && s.ptsUs < clip.timelineEndUs)
513
+ .sort((a, b) => a.ptsUs - b.ptsUs)
514
+
515
+ /*
516
+ Is the texture's timestamp even in media time?
517
+
518
+ On this device it is not: `SurfaceTexture.getTimestamp` returns a
519
+ `System.nanoTime`-based value, about forty-nine hours of uptime, in the
520
+ domain of the release time rather than of the presentation timestamp.
521
+ Treating it as media time called every instant a wrong acquisition — a
522
+ broken check reporting a broken pipeline.
523
+
524
+ So the domain is tested rather than assumed, and when it does not hold the
525
+ identity check falls back to the paired metadata and says it could not
526
+ separate a wrong acquisition from a wrong promotion.
527
+ */
528
+ const skews = inClip
529
+ .filter((s) => s.texturePtsUs >= 0)
530
+ .map((s) => Math.abs(s.texturePtsUs - s.ptsUs))
531
+ .sort((a, b) => a - b)
532
+ const medianSkewUs = skews.length > 0 ? skews[Math.floor(skews.length / 2)]! : -1
533
+ const timestampDomain: TimestampDomain =
534
+ skews.length === 0 ? 'unknown' : medianSkewUs < 1_000_000 ? 'media' : 'system'
535
+ const unique = new Set(inClip.map((s) => s.ptsUs))
536
+
537
+ /*
538
+ What the pixels say arrived, and what they say was shown.
539
+
540
+ `delivered` is every distinct frame index the ring actually held during this
541
+ clip. It is what separates a frame that was never supplied from a frame that
542
+ was supplied and not chosen — the distinction the timestamps could not make,
543
+ and the reason for the band in the first place.
544
+ */
545
+ const delivered = new Set<number>()
546
+ let markersRead = 0
547
+ let markersFailed = 0
548
+ for (const record of inClip) {
549
+ if (record.acquiredMarker >= 0) delivered.add(record.acquiredMarker)
550
+ if (record.submittedMarker >= 0) markersRead += 1
551
+ else if (record.markerFailure !== '' && record.markerFailure !== 'unread') markersFailed += 1
552
+ }
553
+ const pixelIdentity = markersRead > 0
554
+
555
+ /*
556
+ Scored across the span actually covered, not the clip's nominal length.
557
+
558
+ A run that stopped early, or a seek that landed mid-clip, would otherwise
559
+ report every instant it never reached as a missed deadline.
560
+ */
561
+ const first = inClip.length > 0 ? inClip[0]!.ptsUs : 0
562
+ const last = inClip.length > 0 ? inClip[inClip.length - 1]!.ptsUs : 0
563
+ /*
564
+ The frame-derived offset, kept only as a cross-check.
565
+
566
+ It was the anchor once, and it should not have been: an offset computed from
567
+ submission records folds any systematic presentation delay into itself, so a
568
+ pipeline uniformly late measures as punctual. Instants are placed from the
569
+ player's own clock instead, and this is reported beside it — a growing
570
+ difference between the two *is* the presentation delay, which is worth
571
+ seeing rather than absorbing.
572
+ */
573
+ const clockOffsets = inClip
574
+ .filter((r) => r.submittedNs > 0)
575
+ .map((r) => r.submittedNs - r.ptsUs * 1000)
576
+ .sort((a, b) => a - b)
577
+ const playerOffsets = samples
578
+ .filter((s) => s.playing && s.atNs > 0 && s.positionUs >= 0)
579
+ .map((s) => s.atNs - s.positionUs * 1000)
580
+ .sort((a, b) => a - b)
581
+ /*
582
+ The difference between the two clocks, not either one's absolute value.
583
+
584
+ Both are monotonic-minus-project offsets and both are dominated by uptime —
585
+ reported raw, one read as 203,687 seconds, which says nothing. What is worth
586
+ seeing is how far apart they sit: the player says when a moment of project
587
+ time occurred, the frames say when a picture for it was handed over, and the
588
+ gap is the pipeline's systematic presentation delay. Anchoring on the frames
589
+ would subtract exactly that and call the result punctual.
590
+ */
591
+ const frameDerivedOffsetNs =
592
+ clockOffsets.length > 0 && playerOffsets.length > 0
593
+ ? clockOffsets[Math.floor(clockOffsets.length / 2)]! -
594
+ playerOffsets[Math.floor(playerOffsets.length / 2)]!
595
+ : 0
596
+
597
+ let expectedInstants = 0
598
+ let leadingExcluded = 0
599
+ let trailingExcluded = 0
600
+ let covered = 0
601
+ let wrong = 0
602
+ let unverified = 0
603
+ let unplaceable = 0
604
+ let exact = 0
605
+ const offsets: Record<string, number> = {}
606
+ // One source frame, in project time: 16.7 ms at 2x, 66.7 ms at 0.5x. The
607
+ // conversion that makes an offset comparable with the tolerance.
608
+ const sourceIntervalUs = clip.sourceFps > 0 ? 1_000_000 / clip.sourceFps / clip.speed : 0
609
+ const wrongInstants: WrongInstant[] = []
610
+ for (const frame of plan.frames) {
611
+ const projectUs = clip.timelineStartUs + Number(frame.outputPtsUs)
612
+ if (inClip.length === 0) continue
613
+ const boundary = projectUs < first || projectUs > last
614
+ if (projectUs < first) leadingExcluded += 1
615
+ else if (projectUs > last) trailingExcluded += 1
616
+ else expectedInstants += 1
617
+
618
+ const instantNs = wallClockAt(samples, projectUs, outputIntervalUs)
619
+ if (instantNs === null && !boundary) unplaceable += 1
620
+ const shown =
621
+ instantNs === null ? null : displayedAt(inClip, projectUs, instantNs, outputIntervalUs * 1000)
622
+ if (shown === null) {
623
+ /*
624
+ The boundary instants are checked too.
625
+
626
+ They are excluded from the *coverage* arithmetic because the run never
627
+ reached them, but their identity still matters: a leading instant is the
628
+ seam, where the previous clip is still on screen, and dropping it from
629
+ both metrics is how a wrong picture at a clip change goes unseen.
630
+ */
631
+ if (boundary) {
632
+ wrongInstants.push({
633
+ projectUs,
634
+ expectedSourceFrame: frame.sourceFrameIndex,
635
+ expectedSourcePtsUs: Number(frame.sourceTimeUs),
636
+ shownSourceFrame: -1,
637
+ shownPtsUs: -1,
638
+ acquiredTexturePtsUs: -1,
639
+ acquiredMarker: -1,
640
+ submittedMarker: -1,
641
+ slot: -1,
642
+ frameId: -1,
643
+ submittedNs: 0,
644
+ cause: 'uncovered',
645
+ boundary,
646
+ })
647
+ }
648
+ continue
649
+ }
650
+ if (!boundary) covered += 1
651
+
652
+ /*
653
+ Identity by source frame, not by timestamp.
654
+
655
+ The submitted PTS is project time and the rule names a source frame, so
656
+ the comparison maps the shown picture back through the same speed the
657
+ compiler applied. A tolerance of one source frame absorbs the rounding
658
+ between the two directions.
659
+ */
660
+ /*
661
+ What the texture holds, not what the record claims it holds.
662
+
663
+ The two are usually the same and the case where they are not is exactly a
664
+ wrong acquisition: the picture on screen is the texture's content, and
665
+ scoring the record's timestamp instead would call that instant correct.
666
+ */
667
+ const shownProjectUs = timestampDomain === 'media' ? shown.texturePtsUs : shown.ptsUs
668
+ const shownSourceUs = clip.sourceStartUs + (shownProjectUs - clip.timelineStartUs) * clip.speed
669
+ /*
670
+ The marker when there is one, the timestamp only when there is not.
671
+
672
+ A read band names the frame outright; the derived index is an inference
673
+ from the very association under test. When neither is available the
674
+ instant is unverified rather than assumed correct.
675
+ */
676
+ const derivedIndex = Math.floor((shownSourceUs * clip.sourceFps) / 1_000_000)
677
+ const shownIndex = shown.submittedMarker >= 0 ? shown.submittedMarker : derivedIndex
678
+ const verified = shown.submittedMarker >= 0 || timestampDomain === 'media'
679
+ if (!verified) {
680
+ if (!boundary) unverified += 1
681
+ continue
682
+ }
683
+ /*
684
+ Counted before it is judged, and counted whether or not it passes.
685
+
686
+ A pixel oracle can be exact, so it is: the rule names one source frame and
687
+ the band says which one arrived. But "wrong" spans a frame that is 8.3 ms
688
+ early and a frame that is a third of a second stale, and a count alone
689
+ cannot tell them apart.
690
+ */
691
+ const offset = shownIndex - frame.sourceFrameIndex
692
+ /*
693
+ Over the scored span only.
694
+
695
+ A boundary instant is one the run never reached, and what is on screen
696
+ there is the previous clip or the last picture of this one — an offset of
697
+ a few hundred frames that is not jitter and would swamp the distribution.
698
+ Those instants are still checked; they appear in `wrong` flagged
699
+ `boundary`, which is where they belong.
700
+ */
701
+ if (!boundary) {
702
+ const key = String(offset)
703
+ offsets[key] = (offsets[key] ?? 0) + 1
704
+ }
705
+
706
+ /*
707
+ Two contracts, and this is the preview's.
708
+
709
+ An export selects the exact frame `resampleClip` names: it drives the
710
+ decoder to a source time and gets that frame. A preview consumes a live
711
+ decoder at its own display rate, and above 1x the frame the rule names is
712
+ usually never produced at the instant it is wanted — measured at 2x, 31 to
713
+ 37 of about 90 instants wanted a frame no slot ever held, against 0 to 1
714
+ the ring genuinely mis-chose. Requiring exact identity would be asking the
715
+ preview to seek per frame.
716
+
717
+ So the preview is judged temporally: the picture must be within
718
+ `PREVIEW_TOLERANCE_US` of project time from the frame the rule names, and
719
+ dropping frames during accelerated playback is permitted. This is an
720
+ acceptance decision, not a discovery that the identity check passed —
721
+ `exactInstants` and the offset distribution are reported either way, and
722
+ an export must still be scored against exact selection.
723
+ */
724
+ const offsetUs = Math.abs(offset) * sourceIntervalUs
725
+ // The derived index still needs its rounding slack; a band is exact.
726
+ const slack = shown.submittedMarker >= 0 ? 0 : sourceIntervalUs
727
+ if (offset === 0) exact += 1
728
+ if (offsetUs <= Math.max(toleranceUs, slack)) continue
729
+ if (!boundary) wrong += 1
730
+
731
+ /*
732
+ Three different faults, and they send the fix to three different places.
733
+
734
+ The texture's own timestamp is what separates them: if it disagrees with
735
+ the metadata the frame was recorded under, the pairing was wrong before
736
+ the ring saw it. If it agrees and a different frame is on screen, the ring
737
+ chose wrongly. If the right frame is on screen but arrived after its
738
+ instant, nothing chose wrongly and it was simply late.
739
+ */
740
+ const acquired = shown.texturePtsUs
741
+ /*
742
+ Late delivery first, because its evidence stands on its own.
743
+
744
+ Both terms are the ring's own records — when it submitted, and what
745
+ project time it recorded the frame under — and neither comes from the
746
+ texture. It survives the timestamp-domain problem intact, which is why it
747
+ stays a separate cause rather than folding into the unresolved one.
748
+ */
749
+ const late = shown.submittedNs > 0 && shown.ptsUs < projectUs - outputIntervalUs
750
+ /*
751
+ Available *by this instant*, not somewhere in the clip.
752
+
753
+ A clip-global check answers the wrong question: every frame arrives
754
+ eventually, so nothing is ever "not delivered" and a picture that simply
755
+ had not arrived yet is charged to selection. What decides the fault is
756
+ whether the named frame was in the ring when the instant needed it.
757
+ */
758
+ const hadItByThen = inClip.some(
759
+ (r) =>
760
+ r.acquiredMarker === frame.sourceFrameIndex &&
761
+ r.acquiredNs > 0 &&
762
+ r.acquiredNs <= shown.submittedNs,
763
+ )
764
+ /*
765
+ Three outcomes in pixels, and only three.
766
+
767
+ With identity read from the picture, "the right frame submitted late"
768
+ cannot produce a wrong instant: if the marker matches the rule's frame the
769
+ instant is correct, whenever it was submitted. So the pixel path asks only
770
+ whether the storage changed, whether the frame was there to show, and
771
+ whether something else was shown instead. `late-delivery` belongs to the
772
+ timestamp path, where identity is inferred from when a frame was recorded
773
+ rather than from what it contains.
774
+ */
775
+ let cause: WrongInstantCause
776
+ if (shown.submittedMarker >= 0) {
777
+ if (shown.acquiredMarker >= 0 && shown.submittedMarker !== shown.acquiredMarker) {
778
+ cause = 'storage-aliased'
779
+ } else if (!hadItByThen) cause = 'not-delivered'
780
+ else cause = 'wrong-selection'
781
+ } else if (late) cause = 'late-delivery'
782
+ else cause = 'wrong-submitted-identity'
783
+
784
+ wrongInstants.push({
785
+ projectUs,
786
+ expectedSourceFrame: frame.sourceFrameIndex,
787
+ expectedSourcePtsUs: Number(frame.sourceTimeUs),
788
+ shownSourceFrame: shownIndex,
789
+ shownPtsUs: shownProjectUs,
790
+ acquiredTexturePtsUs: acquired,
791
+ acquiredMarker: shown.acquiredMarker,
792
+ submittedMarker: shown.submittedMarker,
793
+ slot: shown.slot,
794
+ frameId: shown.frameId,
795
+ submittedNs: shown.submittedNs,
796
+ cause,
797
+ boundary,
798
+ })
799
+ }
800
+
801
+ const walls: number[] = []
802
+ for (let i = 1; i < inClip.length; i += 1) {
803
+ const gap = (inClip[i]!.submittedNs - inClip[i - 1]!.submittedNs) / 1000
804
+ if (gap > 0) walls.push(gap)
805
+ }
806
+ walls.sort((a, b) => a - b)
807
+ const tail = (fraction: number) =>
808
+ walls.length === 0
809
+ ? 0
810
+ : Math.round(walls[Math.min(walls.length - 1, Math.floor(walls.length * fraction))]!)
811
+ const frameTimes = {
812
+ p50: tail(0.5),
813
+ p95: tail(0.95),
814
+ p99: tail(0.99),
815
+ max: walls.length > 0 ? Math.round(walls[walls.length - 1]!) : 0,
816
+ samples: walls.length,
817
+ }
818
+
819
+ /*
820
+ Parity over the submissions, not over the instants.
821
+
822
+ Rendering is a property of a picture, and the same picture can cover several
823
+ instants; scoring it once per instant would weight a held frame more heavily
824
+ than a fleeting one for no reason.
825
+ */
826
+ let parityMeasured = 0
827
+ let worstLevelDelta = 0
828
+ let worstBarDelta = 0
829
+ let parityFailures = 0
830
+ for (const record of inClip) {
831
+ if (record.levelDelta < 0 || record.barDelta < 0) continue
832
+ parityMeasured += 1
833
+ if (record.levelDelta > worstLevelDelta) worstLevelDelta = record.levelDelta
834
+ if (record.barDelta > worstBarDelta) worstBarDelta = record.barDelta
835
+ if (record.levelDelta > PARITY_LEVEL_TOLERANCE || record.barDelta > PARITY_BAR_TOLERANCE) {
836
+ parityFailures += 1
837
+ }
838
+ }
839
+
840
+ const stamps = [...unique].sort((a, b) => a - b)
841
+ const holds: number[] = []
842
+ for (let i = 1; i < stamps.length; i += 1) holds.push(stamps[i]! - stamps[i - 1]!)
843
+ const repeats = Math.max(1, Math.round(1 / clip.speed))
844
+ const expectedHoldUs = outputIntervalUs * repeats
845
+ const missedDeadlines = holds.filter((h) => h > expectedHoldUs * 1.5).length
846
+ holds.sort((a, b) => a - b)
847
+
848
+ /*
849
+ Nothing measured is not a failing measurement.
850
+
851
+ Checked before anything is judged: a run with no submissions, no readable
852
+ band, or no parity reading has established nothing about this clip, and
853
+ saying `fail` would be as wrong as saying `pass`.
854
+ */
855
+ let verdict: Verdict = 'pass'
856
+ let verdictReason = ''
857
+ if (expectedInstants === 0) {
858
+ verdict = 'invalid'
859
+ verdictReason = 'no instants covered'
860
+ } else if (!pixelIdentity) {
861
+ verdict = 'invalid'
862
+ verdictReason = 'identity not read from pixels'
863
+ } else if (parityMeasured === 0) {
864
+ verdict = 'invalid'
865
+ verdictReason = 'rendering parity not measured'
866
+ } else if (unplaceable > 0) {
867
+ verdict = 'invalid'
868
+ verdictReason = `${unplaceable} instants could not be placed on the clock`
869
+ } else if (unverified > 0) {
870
+ verdict = 'invalid'
871
+ verdictReason = `${unverified} instants unverified`
872
+ } else if (wrong > 0) {
873
+ verdict = 'fail'
874
+ verdictReason = `${wrong} wrong instants`
875
+ } else if (covered !== expectedInstants) {
876
+ verdict = 'fail'
877
+ verdictReason = `${expectedInstants - covered} instants uncovered`
878
+ } else if (missedDeadlines > 0) {
879
+ verdict = 'fail'
880
+ verdictReason = `${missedDeadlines} missed deadlines`
881
+ } else if (parityFailures > 0) {
882
+ verdict = 'fail'
883
+ verdictReason = `${parityFailures} parity failures`
884
+ }
885
+
886
+ return {
887
+ clip: clip.name,
888
+ nominalInstants: plan.frames.length,
889
+ leadingExcluded,
890
+ trailingExcluded,
891
+ expectedInstants,
892
+ coveredInstants: covered,
893
+ wrongFrameInstants: wrong,
894
+ uncoveredInstants: expectedInstants - covered,
895
+ submittedUnique: unique.size,
896
+ ruleSkippedSourceFrames: plan.droppedSourceFrameCount,
897
+ outputIntervalUs,
898
+ medianHoldUs: holds.length > 0 ? holds[Math.floor(holds.length / 2)]! : 0,
899
+ maxHoldUs: holds.length > 0 ? holds[holds.length - 1]! : 0,
900
+ expectedHoldUs,
901
+ missedDeadlines,
902
+ parity: {
903
+ measured: parityMeasured,
904
+ worstLevelDelta,
905
+ worstBarDelta: Number(worstBarDelta.toFixed(5)),
906
+ failures: parityFailures,
907
+ // No measurement is not a pass: a run that never looked has not looked.
908
+ pass: parityMeasured > 0 && parityFailures === 0,
909
+ },
910
+ offsets,
911
+ unplaceableInstants: unplaceable,
912
+ frameDerivedOffsetUs: Math.round(frameDerivedOffsetNs / 1000),
913
+ exactInstants: exact,
914
+ worstOffsetUs: Math.round(
915
+ Object.keys(offsets).reduce((worst, k) => Math.max(worst, Math.abs(Number(k))), 0) *
916
+ (clip.sourceFps > 0 ? 1_000_000 / clip.sourceFps / clip.speed : 0),
917
+ ),
918
+ identity: {
919
+ timestampDomain,
920
+ medianTextureSkewUs: medianSkewUs,
921
+ pixelIdentity,
922
+ identityUnverified: unverified,
923
+ deliveredFrames: delivered.size,
924
+ markersRead,
925
+ markersFailed,
926
+ },
927
+ frameTimeUs: frameTimes,
928
+ wrong: wrongInstants,
929
+ // Every expected instant covered, by the frame the shared rule names, and
930
+ // no picture held past its turn.
931
+ /*
932
+ An unverified instant is not a passing one.
933
+
934
+ With the probe off and the timestamps in the system domain there is no
935
+ evidence about which frame was shown, and a check that reported that
936
+ absence as success would be the same mistake as the one the band was
937
+ built to correct.
938
+ */
939
+ /*
940
+ The preview contract: temporally accurate, correctly rendered, covered.
941
+
942
+ Exact export-frame identity is deliberately absent. `exactInstants` and
943
+ `offsets` record it, and `@see PREVIEW_TOLERANCE_US` for why requiring it
944
+ here would be requiring a per-frame seek.
945
+ */
946
+ pass: verdict === 'pass',
947
+ verdict,
948
+ verdictReason,
949
+ }
950
+ }