@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,405 @@
1
+ import type { SpikeSample } from './compositionSpike'
2
+
3
+ /**
4
+ * Where a shared-clock run left the tolerance, and why.
5
+ *
6
+ * ## What is being compared
7
+ *
8
+ * The audio clock's position and the presented frame's timestamp, brought to
9
+ * one instant. The frame was latched at `frameConsumedNs`; the position was
10
+ * read at `atNs`. Rolling the position back to the latch is what puts them on
11
+ * the same monotonic instant — the composition runs at project rate 1x, so the
12
+ * roll-back is the elapsed time itself.
13
+ *
14
+ * ## Which boundary this observes
15
+ *
16
+ * The **latch**, not the display. `frameConsumedNs` is when the consumer called
17
+ * `updateTexImage`; `frameReleaseNs` is the time Media3 *asked* the surface to
18
+ * present at, which is an intention and not an acknowledgement. Actual scanout
19
+ * happens one compositor pass later and is not observed anywhere in this path.
20
+ * So an offset here is "audio against latched picture", and the display adds a
21
+ * further unmeasured delay common to every sample.
22
+ *
23
+ * ## Uncertainty is stated, never subtracted
24
+ *
25
+ * Comparing a 30 fps frame's timestamp against a continuous clock carries an
26
+ * irreducible ±16.7 ms, and the paired read adds a few more. That is reported
27
+ * beside the verdict. It is not deducted from an excursion to bring it under
28
+ * the line: a 95 ms excursion is not a 78 ms excursion because the instrument
29
+ * is imperfect.
30
+ *
31
+ * @experimental
32
+ */
33
+
34
+ /** @experimental */
35
+ export const SPIKE_TOLERANCE_US = 60_000
36
+
37
+ /**
38
+ * Why one sample left the tolerance.
39
+ *
40
+ * Ordered by precedence, because more than one can be true and the first is the
41
+ * one that explains the sample: a frame from before a seek is cross-epoch
42
+ * whatever else is also true of it.
43
+ *
44
+ * @experimental
45
+ */
46
+ export type ExcursionCause =
47
+ /**
48
+ * The latched frame predates the moment playback was released.
49
+ *
50
+ * Not a filter and not a drift. The readiness gate releases on a frame whose
51
+ * *position* matches the epoch, and that frame may have been latched long
52
+ * before — measured at 13.9 s, the whole interval between attaching the
53
+ * composition and the person pressing play. The picture was right; the
54
+ * frame's wall-clock age simply includes a pause, and charging that to
55
+ * synchronisation measures the pause.
56
+ *
57
+ * Reported rather than excluded, because the size of it is the size of the
58
+ * interval in which the first sample after a release is not yet a
59
+ * synchronisation reading.
60
+ */
61
+ | 'pre-release-frame'
62
+ /** The frame belongs to a different transition epoch or frame generation. */
63
+ | 'cross-epoch'
64
+ /** The frame was latched long enough ago that it is not a current reading. */
65
+ | 'stale-pairing'
66
+ /** The first frames after a seek or after readiness, before the picture caught up. */
67
+ | 'first-frame-after-seek'
68
+ /** The frame was latched well after the time it was asked to be presented at. */
69
+ | 'delayed-presentation'
70
+ /** The audio clock moved discontinuously between adjacent samples. */
71
+ | 'audio-discontinuity'
72
+ /** The mapped project time disagrees with the frame's own window and PTS. */
73
+ | 'mapping'
74
+ /** None of the above fitted. Deliberately not folded into a neighbour. */
75
+ | 'unexplained'
76
+
77
+ /** @experimental */
78
+ export interface Excursion {
79
+ seq: number
80
+ /** Against the latch: observed, and earlier than display. */
81
+ offsetUs: number
82
+ /** Against the time the frame was asked to appear. An intention. */
83
+ offsetAtIntendedUs: number
84
+ cause: ExcursionCause
85
+ epoch: number
86
+ sinceEpochMs: number
87
+ frameWindow: number
88
+ framePtsUs: number
89
+ /** Latched minus asked-for. Large means the picture arrived late. */
90
+ latchLagUs: number
91
+ /** How old the latched frame was when the position was read. */
92
+ frameAgeUs: number
93
+ /** The audio clock's step from the previous sample, in microseconds. */
94
+ positionStepUs: number
95
+ /** The wall-clock step from the previous sample. */
96
+ wallStepUs: number
97
+ }
98
+
99
+ /** @experimental */
100
+ export interface ExcursionReport {
101
+ toleranceUs: number
102
+ /** Samples where playback was declared active. Only these are judged. */
103
+ activeSamples: number
104
+ /** Samples before readiness, exposed rather than dropped. */
105
+ preparingSamples: number
106
+ excursions: Excursion[]
107
+ byCause: Record<string, number>
108
+ /**
109
+ * Samples still showing a frame latched before the release.
110
+ *
111
+ * Reported apart, and *retained* in the acceptance set: they are real
112
+ * playback, and if the picture never catches up they are the only thing that
113
+ * says so.
114
+ */
115
+ preReleaseSamples: number
116
+ preReleaseWorstUs: number
117
+ worstAbsUs: number
118
+ /** The same run judged at the intended-presentation boundary. */
119
+ worstAtIntendedUs: number
120
+ overAtIntended: number
121
+ passAtIntended: boolean
122
+ /** Mean latch-minus-intended over the excursions. Negative means early. */
123
+ meanLatchLagUs: number
124
+ pass: boolean
125
+ uncertainty: {
126
+ /** Half a frame interval at the measured rate. Irreducible. */
127
+ frameQuantumUs: number
128
+ /** The widest paired read in the run. */
129
+ readSpanUs: number
130
+ positionQuantumUs: number
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The offset at each observable boundary.
136
+ *
137
+ * They are not the same instant and the difference is not noise. Measured on a
138
+ * Pixel 7, the consumer latches a buffer about 34 ms *before* the time Media3
139
+ * asked the surface to present it — `releaseOutputBuffer(index, releaseTimeNs)`
140
+ * queues immediately with a desired present time in the future, and the
141
+ * SurfaceTexture consumer takes it as soon as it is queued.
142
+ *
143
+ * So `atLatch` compares the audio clock against a frame that is in the texture
144
+ * but has not been displayed, and is systematically early by that gap;
145
+ * `atIntended` compares it against when that frame was *meant* to appear, which
146
+ * is the best available estimate of what a viewer experiences and is still an
147
+ * intention rather than an acknowledgement. The true display instant lies at or
148
+ * after `atIntended`, and nothing in this process observes it.
149
+ *
150
+ * Both are returned because choosing one silently is how a boundary decision
151
+ * becomes a verdict.
152
+ *
153
+ * @experimental
154
+ */
155
+ export function offsetAt(
156
+ sample: SpikeSample,
157
+ /**
158
+ * When the current playing interval was released, if known.
159
+ *
160
+ * A frame latched while paused carries the pause in its age. Charging that to
161
+ * synchronisation measured a 13.9 second "error" for a picture that was
162
+ * showing exactly the right position. Clamping the reference to the release
163
+ * instant charges only the time playback has actually been running — so at
164
+ * release the offset is (audio position - displayed PTS), and it grows from
165
+ * there if the audio advances while the picture stays put.
166
+ *
167
+ * That growth is the point: this corrects the baseline without removing
168
+ * coverage. A frame that is never replaced still accrues the offset it
169
+ * deserves.
170
+ */
171
+ releaseAtNs = 0,
172
+ ): { atLatch: number; atIntended: number } {
173
+ /*
174
+ A frame already on screen when playback began has no age to subtract.
175
+
176
+ For a frame presented during playback, the sound being ahead of it by the
177
+ time since it appeared is not error — the frame is a sample of the recent
178
+ past. Subtracting that age is what makes the measure a synchronisation
179
+ error rather than a restatement of the frame rate.
180
+
181
+ A pre-release frame is different in kind. It was displayed before the clock
182
+ started, so there is no elapsed-since-display to discount: at the moment of
183
+ release the comparison is simply "what is on screen" against "where the
184
+ sound is", which is near zero when the readiness gate did its job. And if
185
+ the picture then stays put while the sound runs on, the gap is exactly the
186
+ staleness a viewer sees — so it is counted, not cancelled. Subtracting the
187
+ age here would have the two grow together and report zero forever.
188
+ */
189
+ if (releaseAtNs > 0 && sample.frameConsumedNs < releaseAtNs) {
190
+ const raw = sample.positionUs - sample.framePtsUs
191
+ return { atLatch: raw, atIntended: raw }
192
+ }
193
+ return {
194
+ atLatch: sample.positionUs - (sample.atNs - sample.frameConsumedNs) / 1000 - sample.framePtsUs,
195
+ atIntended:
196
+ sample.positionUs - (sample.atNs - sample.frameReleaseNs) / 1000 - sample.framePtsUs,
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Classify a run's over-tolerance samples.
202
+ *
203
+ * `frameIntervalUs` is the source's nominal frame interval; half of it is the
204
+ * irreducible quantisation of comparing a discrete frame against a continuous
205
+ * clock.
206
+ *
207
+ * @experimental
208
+ */
209
+ export function classifyExcursions(
210
+ samples: readonly SpikeSample[],
211
+ options: { toleranceUs?: number; frameIntervalUs?: number } = {},
212
+ ): ExcursionReport {
213
+ const toleranceUs = options.toleranceUs ?? SPIKE_TOLERANCE_US
214
+ const frameIntervalUs = options.frameIntervalUs ?? 33_333
215
+
216
+ /*
217
+ Judged only once playback is active, and the rest is exposed rather than
218
+ dropped. A position that has not started moving is not synchronised or
219
+ unsynchronised; counting it either way is a statement about nothing.
220
+ */
221
+ /*
222
+ A frame from before this epoch is not a reading about this epoch.
223
+
224
+ After a start or a seek the clock moves immediately while the first frame
225
+ for the new position is still being decoded, so the newest latched frame is
226
+ the one from *before* the transition. Pairing it against the moving clock
227
+ produces an excursion the size of the seek — measured here at up to 13.8 s
228
+ — and no classification of that number is about synchronisation.
229
+
230
+ So the interval is exposed as preparing rather than judged, which is also
231
+ what makes "include every sample once playback is active" mean something:
232
+ active begins when the picture for this epoch has actually arrived.
233
+ */
234
+ /*
235
+ And the clock has to be moving.
236
+
237
+ `isPlaying` and `STATE_READY` both go true before the composition's clock
238
+ starts: measured, the player reported both for fourteen seconds with its
239
+ position at zero and exactly one frame ever latched. Judging that interval
240
+ produced a 13.9 second "excursion" that is not a synchronisation reading at
241
+ all — it is the preparation the brief asks to be exposed rather than scored.
242
+
243
+ Observed rather than assumed: the position must have changed somewhere in
244
+ the preceding window, so the test is "this clock is running" and not "this
245
+ player says it is".
246
+ */
247
+ const ADVANCE_WINDOW_NS = 200_000_000
248
+ const advancing = samples.map((s, i) => {
249
+ for (let j = i - 1; j >= 0; j -= 1) {
250
+ if (s.atNs - samples[j]!.atNs > ADVANCE_WINDOW_NS) break
251
+ if (samples[j]!.positionUs !== s.positionUs) return true
252
+ }
253
+ return false
254
+ })
255
+
256
+ /*
257
+ When the current playing interval began.
258
+
259
+ A frame latched before it carries the pause in its age, so the first
260
+ samples after a release are not yet synchronisation readings. Tracked so
261
+ they can be *named* rather than dropped.
262
+ */
263
+ const releaseAtNs: number[] = []
264
+ let intervalStart = 0
265
+ for (let i = 0; i < samples.length; i += 1) {
266
+ if (!advancing[i]) intervalStart = 0
267
+ else if (intervalStart === 0) intervalStart = samples[i]!.atNs
268
+ releaseAtNs.push(intervalStart)
269
+ }
270
+
271
+ const isActive = (s: SpikeSample, i: number) =>
272
+ s.playing &&
273
+ s.ready &&
274
+ s.framePtsUs >= 0 &&
275
+ s.frameConsumedNs > 0 &&
276
+ // The picture for this epoch has arrived.
277
+ s.frameConsumedNs >= s.atNs - s.sinceEpochNs &&
278
+ advancing[i] === true
279
+ const active = samples.filter(isActive)
280
+ const preparing = samples.length - active.length
281
+
282
+ const excursions: Excursion[] = []
283
+ const byCause: Record<string, number> = {}
284
+ let worstAbsUs = 0
285
+ let worstAtIntendedUs = 0
286
+ let overAtIntended = 0
287
+ let readSpanUs = 0
288
+ let latchLagSum = 0
289
+ let preReleaseSamples = 0
290
+ let preReleaseWorstUs = 0
291
+
292
+ // Indexed by the sample's own position in the full run, because the release
293
+ // boundary was computed there and `active` has holes.
294
+ const releaseFor = new Map<number, number>()
295
+ samples.forEach((s, i) => releaseFor.set(s.seq, releaseAtNs[i]!))
296
+
297
+ for (let i = 0; i < active.length; i += 1) {
298
+ const s = active[i]!
299
+ const previous = i > 0 ? active[i - 1]! : null
300
+ readSpanUs = Math.max(readSpanUs, s.readSpanNs / 1000)
301
+ const releasedAt = releaseFor.get(s.seq) ?? 0
302
+ const both = offsetAt(s, releasedAt)
303
+ const offsetUs = both.atLatch
304
+ const abs = Math.abs(offsetUs)
305
+ const absIntended = Math.abs(both.atIntended)
306
+ if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
307
+ preReleaseSamples += 1
308
+ if (abs > preReleaseWorstUs) preReleaseWorstUs = abs
309
+ }
310
+ if (abs > worstAbsUs) worstAbsUs = abs
311
+ if (absIntended > worstAtIntendedUs) worstAtIntendedUs = absIntended
312
+ if (absIntended > toleranceUs) overAtIntended += 1
313
+ if (abs <= toleranceUs) continue
314
+
315
+ const frameAgeUs = (s.atNs - s.frameConsumedNs) / 1000
316
+ const latchLagUs = (s.frameConsumedNs - s.frameReleaseNs) / 1000
317
+ latchLagSum += latchLagUs
318
+ const positionStepUs = previous ? s.positionUs - previous.positionUs : 0
319
+ const wallStepUs = previous ? (s.atNs - previous.atNs) / 1000 : 0
320
+
321
+ /*
322
+ First match wins, and the order is the point.
323
+
324
+ A frame carried across a seek is cross-epoch whether or not it is also
325
+ old and also late; calling it "delayed presentation" would send the fix
326
+ to the renderer instead of to the epoch check.
327
+ */
328
+ let cause: ExcursionCause
329
+ if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
330
+ cause = 'pre-release-frame'
331
+ } else if (s.frameConsumedNs > 0 && s.frameConsumedNs < s.atNs - s.sinceEpochNs) {
332
+ cause = 'cross-epoch'
333
+ } else if (previous && s.frameGeneration !== previous.frameGeneration) {
334
+ cause = 'cross-epoch'
335
+ } else if (frameAgeUs > 2 * frameIntervalUs) {
336
+ cause = 'stale-pairing'
337
+ } else if (s.sinceEpochNs / 1000 < 500_000) {
338
+ cause = 'first-frame-after-seek'
339
+ } else if (latchLagUs > frameIntervalUs) {
340
+ cause = 'delayed-presentation'
341
+ } else if (previous && Math.abs(positionStepUs - wallStepUs) > toleranceUs) {
342
+ cause = 'audio-discontinuity'
343
+ } else if (previous && s.frameWindow !== previous.frameWindow) {
344
+ cause = 'mapping'
345
+ } else {
346
+ cause = 'unexplained'
347
+ }
348
+
349
+ byCause[cause] = (byCause[cause] ?? 0) + 1
350
+ excursions.push({
351
+ seq: s.seq,
352
+ offsetUs: Math.round(offsetUs),
353
+ offsetAtIntendedUs: Math.round(both.atIntended),
354
+ cause,
355
+ epoch: s.epoch,
356
+ sinceEpochMs: Math.round(s.sinceEpochNs / 1_000_000),
357
+ frameWindow: s.frameWindow,
358
+ framePtsUs: s.framePtsUs,
359
+ latchLagUs: Math.round(latchLagUs),
360
+ frameAgeUs: Math.round(frameAgeUs),
361
+ positionStepUs: Math.round(positionStepUs),
362
+ wallStepUs: Math.round(wallStepUs),
363
+ })
364
+ }
365
+
366
+ return {
367
+ toleranceUs,
368
+ activeSamples: active.length,
369
+ preparingSamples: preparing,
370
+ excursions,
371
+ byCause,
372
+ preReleaseSamples,
373
+ preReleaseWorstUs: Math.round(preReleaseWorstUs),
374
+ worstAbsUs: Math.round(worstAbsUs),
375
+ worstAtIntendedUs: Math.round(worstAtIntendedUs),
376
+ overAtIntended,
377
+ passAtIntended: active.length > 0 && overAtIntended === 0,
378
+ meanLatchLagUs: excursions.length
379
+ ? Math.round(latchLagSum / excursions.length)
380
+ : 0,
381
+ // No samples is not a pass. A run that never played has not been judged.
382
+ pass: active.length > 0 && excursions.length === 0,
383
+ uncertainty: {
384
+ frameQuantumUs: Math.round(frameIntervalUs / 2),
385
+ readSpanUs: Math.round(readSpanUs),
386
+ positionQuantumUs: 1000,
387
+ },
388
+ }
389
+ }
390
+
391
+ /**
392
+ * The samples around one excursion, for looking at rather than inferring from.
393
+ *
394
+ * @experimental
395
+ */
396
+ export function windowAround(
397
+ samples: readonly SpikeSample[],
398
+ seq: number,
399
+ spanUs = 250_000,
400
+ ): SpikeSample[] {
401
+ const centre = samples.find((s) => s.seq === seq)
402
+ if (!centre) return []
403
+ const spanNs = spanUs * 1000
404
+ return samples.filter((s) => Math.abs(s.atNs - centre.atNs) <= spanNs)
405
+ }