@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,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
|
+
}
|