@pictor-sdk/video-android 0.1.5 → 0.1.6

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.
@@ -212,6 +212,9 @@ internal object CompositionSpike {
212
212
  private set
213
213
 
214
214
  @Volatile var readinessTimeouts = 0L
215
+
216
+ /** How many resumes had to be re-primed because no frame could satisfy them. */
217
+ @Volatile var primedResumes = 0L
215
218
  private set
216
219
 
217
220
  /** How long a hold may wait before it is reported as a failure. */
@@ -408,19 +411,24 @@ internal object CompositionSpike {
408
411
  */
409
412
  if (seamHoldTargetUs >= 0) return pts >= seamHoldTargetUs
410
413
 
411
- return pts >= epochTargetUs - FRAME_MATCH_BACK_US &&
412
- pts <= epochTargetUs + FRAME_MATCH_AHEAD_US
414
+ return ResumeReadiness.matches(pts, epochTargetUs)
413
415
  }
414
416
 
415
417
  /**
416
- * How far a frame may sit from the epoch's target and still be its first.
418
+ * The newest frame the pipeline actually has, or null if there is none.
417
419
  *
418
- * Backwards by about a frame, because a seek lands on the preceding
419
- * keyframe-derived sample; forwards by a second, because the first frame
420
- * after a seek into a long GOP can legitimately be later than asked for.
420
+ * The same three checks the gate makes before it will look at a timestamp:
421
+ * latched rather than merely offered, pixels and timestamp from the same
422
+ * frame, and a usable project time. A caller deciding where to re-prime has
423
+ * to be reading the same frame the gate will be judging.
421
424
  */
422
- private const val FRAME_MATCH_BACK_US = 40_000L
423
- private const val FRAME_MATCH_AHEAD_US = 1_000_000L
425
+ private fun latchedFramePtsUs(): Long? {
426
+ val frame = FrameClock.latestSample() ?: return null
427
+ if (frame[FrameClock.Field.CONSUMED_NS] <= 0L) return null
428
+ if (frame[FrameClock.Field.ASSOCIATION] != 1L) return null
429
+ val pts = frame[FrameClock.Field.PTS_US]
430
+ return if (pts < 0L) null else pts
431
+ }
424
432
 
425
433
  /** Retained evidence, so an excursion can be looked at rather than inferred. */
426
434
  private const val RING = 8192
@@ -660,6 +668,7 @@ internal object CompositionSpike {
660
668
  .put("releases", releases)
661
669
  .put("readinessTimeouts", readinessTimeouts)
662
670
  .put("readinessFailure", readinessFailure ?: JSONObject.NULL)
671
+ .put("primedResumes", primedResumes)
663
672
  /*
664
673
  Hold durations, listed rather than summarised.
665
674
 
@@ -912,7 +921,38 @@ internal object CompositionSpike {
912
921
  fun play(): String = onPlayer {
913
922
  intentPlaying = true
914
923
  // Resume targets where the transport already is.
915
- armRelease(it.currentPosition * 1000)
924
+ val clockUs = it.currentPosition * 1000
925
+ /*
926
+ Unless nothing can show that position.
927
+
928
+ Coming back from the background the renderer has stopped latching
929
+ while the transport kept counting, so the newest frame can sit a
930
+ frame or two behind the clock — past the gate's backward tolerance.
931
+ Arming there asks for a frame the decoder will never produce while it
932
+ is paused, and a seek to the position it already reports need not
933
+ produce one either, so the hold times out and every later press of
934
+ play repeats the same impossible wait.
935
+
936
+ Re-primed instead of relaxed: playback seeks to the timestamp of the
937
+ frame on screen, which is a real position and a different one, so the
938
+ decoder emits for it and the gate releases on a frame made for this
939
+ epoch. See `ResumeReadiness`.
940
+ */
941
+ val primeUs =
942
+ if (seamHoldTargetUs >= 0) null
943
+ else ResumeReadiness.primeTargetUs(clockUs, latchedFramePtsUs())
944
+ if (primeUs == null) {
945
+ armRelease(clockUs)
946
+ return@onPlayer
947
+ }
948
+ // A new epoch, for the same reason a seek opens one: what is in flight
949
+ // describes the position being left, not the one being asked for.
950
+ epoch += 1
951
+ epochAtNs = System.nanoTime()
952
+ FrameClock.bump()
953
+ primedResumes += 1
954
+ armRelease(primeUs)
955
+ it.seekTo(primeUs / 1000)
916
956
  }
917
957
 
918
958
  fun pause(): String = onPlayer {
@@ -0,0 +1,82 @@
1
+ package dev.pictor.video
2
+
3
+ /**
4
+ * Where a resume may be released, and where it has to be re-primed first.
5
+ *
6
+ * The readiness gate releases the transport only on a frame that shows the
7
+ * epoch's position. That is the right rule, and it is kept exactly: this is the
8
+ * arithmetic behind it, pulled out so it can be reasoned about and tested
9
+ * without a player.
10
+ *
11
+ * ## The failure it exists for
12
+ *
13
+ * A resume's target is the transport clock, and the clock can sit ahead of the
14
+ * newest frame that exists. When the app goes to the background the renderer
15
+ * stops latching before the transport stops counting, so the last frame on
16
+ * screen ends up a frame or two behind the position the player reports. Arming
17
+ * on the clock then asks for a frame that nothing will produce — the decoder is
18
+ * paused, and Media3 need not emit anything for a seek to the position it is
19
+ * already at — so the gate waits out its three seconds and the transport is
20
+ * left stopped. Pressing play again re-arms the same impossible wait.
21
+ *
22
+ * Measured on a Pixel 7, opening the system document picker from "Add music":
23
+ * targets 46,667 us and 53,334 us ahead of the newest frame against a 40,000 us
24
+ * backward tolerance, wedging two runs in five. The runs that recovered had
25
+ * gaps inside the tolerance.
26
+ *
27
+ * ## What is done about it
28
+ *
29
+ * Re-prime, rather than relax. When no frame can satisfy the hold, playback
30
+ * seeks to the timestamp of the frame already on screen — a real position that
31
+ * differs from the clock, so the decoder emits for it — and the gate then
32
+ * releases on a frame produced for this epoch. The tolerances are untouched,
33
+ * nothing starts on an unverified frame, and the distance moved is whatever the
34
+ * renderer fell behind rather than a nudge chosen to clear the comparison.
35
+ */
36
+ internal object ResumeReadiness {
37
+
38
+ /**
39
+ * How far a frame may sit from the epoch's target and still be its first.
40
+ *
41
+ * Backwards by about a frame, because a seek lands on the preceding
42
+ * keyframe-derived sample; forwards by a second, because the first frame
43
+ * after a seek into a long GOP can legitimately be later than asked for.
44
+ */
45
+ const val FRAME_MATCH_BACK_US = 40_000L
46
+ const val FRAME_MATCH_AHEAD_US = 1_000_000L
47
+
48
+ /** Whether a frame at [framePtsUs] shows the position [targetUs] asks for. */
49
+ fun matches(framePtsUs: Long, targetUs: Long): Boolean =
50
+ framePtsUs >= targetUs - FRAME_MATCH_BACK_US &&
51
+ framePtsUs <= targetUs + FRAME_MATCH_AHEAD_US
52
+
53
+ /**
54
+ * The position a stalled resume has to re-prime at, or null to wait as usual.
55
+ *
56
+ * [framePtsUs] is the newest latched, associated frame of the current
57
+ * generation, or null when there is none — after a seek, for instance, where
58
+ * `FrameClock.bump` has cleared it and the seek's own frame is on its way.
59
+ * With nothing latched there is nothing to re-prime at and nothing to
60
+ * conclude, so the ordinary wait applies.
61
+ *
62
+ * Returns null when the gate can already be satisfied, so an ordinary start
63
+ * or seek is completely unaffected: this only fires where playback would
64
+ * otherwise wait for a frame that cannot arrive.
65
+ *
66
+ * A frame further behind than [FRAME_MATCH_AHEAD_US] is not treated as the
67
+ * current picture at all. That is a stalled pipeline rather than a renderer
68
+ * that stopped a couple of frames early, and seeking a second backwards to
69
+ * meet it would be starting playback somewhere nobody asked for. It is left
70
+ * to time out and be reported, which is what that timeout is for.
71
+ */
72
+ fun primeTargetUs(clockUs: Long, framePtsUs: Long?): Long? {
73
+ if (framePtsUs == null) return null
74
+ if (matches(framePtsUs, clockUs)) return null
75
+ // Ahead of the clock is not this problem: a frame in front of the target
76
+ // is already covered by the forward tolerance, and seeking back to it
77
+ // would move playback the wrong way.
78
+ if (framePtsUs > clockUs) return null
79
+ if (framePtsUs < clockUs - FRAME_MATCH_AHEAD_US) return null
80
+ return framePtsUs
81
+ }
82
+ }
package/dist/index.d.mts CHANGED
@@ -1026,6 +1026,19 @@ interface CompositionPlaybackState {
1026
1026
  width: number;
1027
1027
  height: number;
1028
1028
  error: string | null;
1029
+ /**
1030
+ * Why the transport refused to start, or null.
1031
+ *
1032
+ * Playback is released on a frame that shows the position asked for, and if
1033
+ * no such frame arrives the hold times out and the transport stays stopped
1034
+ * rather than starting on an unverified picture. That used to be invisible:
1035
+ * the control stayed enabled, the press was accepted, and nothing happened,
1036
+ * with no way for a host to tell that apart from a person who had not pressed
1037
+ * it yet. Pressing play again clears it and tries afresh.
1038
+ *
1039
+ * @experimental
1040
+ */
1041
+ readinessFailure: string | null;
1029
1042
  }
1030
1043
  /** @experimental */
1031
1044
  type CompositionPlayback = Omit<Media3Playback, 'state'> & {
@@ -2501,6 +2514,16 @@ interface CompositionSpikeState {
2501
2514
  releases?: number;
2502
2515
  readinessTimeouts?: number;
2503
2516
  readinessFailure?: string | null;
2517
+ /**
2518
+ * How many resumes had to be re-primed because no frame could satisfy them.
2519
+ *
2520
+ * A resume whose target sits past the newest frame cannot be released on
2521
+ * anything that exists, so playback seeks to the frame on screen to make the
2522
+ * decoder produce one. Counted because it is a recovery, not a normal path:
2523
+ * a number that climbs steadily is the renderer falling behind the transport
2524
+ * more often than it should.
2525
+ */
2526
+ primedResumes?: number;
2504
2527
  /** How long the last hold lasted before playback was released. */
2505
2528
  holdMs?: number;
2506
2529
  /**
package/dist/index.d.ts CHANGED
@@ -1026,6 +1026,19 @@ interface CompositionPlaybackState {
1026
1026
  width: number;
1027
1027
  height: number;
1028
1028
  error: string | null;
1029
+ /**
1030
+ * Why the transport refused to start, or null.
1031
+ *
1032
+ * Playback is released on a frame that shows the position asked for, and if
1033
+ * no such frame arrives the hold times out and the transport stays stopped
1034
+ * rather than starting on an unverified picture. That used to be invisible:
1035
+ * the control stayed enabled, the press was accepted, and nothing happened,
1036
+ * with no way for a host to tell that apart from a person who had not pressed
1037
+ * it yet. Pressing play again clears it and tries afresh.
1038
+ *
1039
+ * @experimental
1040
+ */
1041
+ readinessFailure: string | null;
1029
1042
  }
1030
1043
  /** @experimental */
1031
1044
  type CompositionPlayback = Omit<Media3Playback, 'state'> & {
@@ -2501,6 +2514,16 @@ interface CompositionSpikeState {
2501
2514
  releases?: number;
2502
2515
  readinessTimeouts?: number;
2503
2516
  readinessFailure?: string | null;
2517
+ /**
2518
+ * How many resumes had to be re-primed because no frame could satisfy them.
2519
+ *
2520
+ * A resume whose target sits past the newest frame cannot be released on
2521
+ * anything that exists, so playback seeks to the frame on screen to make the
2522
+ * decoder produce one. Counted because it is a recovery, not a normal path:
2523
+ * a number that climbs steadily is the renderer falling behind the transport
2524
+ * more often than it should.
2525
+ */
2526
+ primedResumes?: number;
2504
2527
  /** How long the last hold lasted before playback was released. */
2505
2528
  holdMs?: number;
2506
2529
  /**
package/dist/index.js CHANGED
@@ -884,7 +884,8 @@ function composedState(snapshot) {
884
884
  */
885
885
  width: 0,
886
886
  height: 0,
887
- error: snapshot.error ?? null
887
+ error: snapshot.error ?? null,
888
+ readinessFailure: snapshot.readinessFailure ?? null
888
889
  };
889
890
  }
890
891
  function useCompositionPlayback(options) {