@pictor-sdk/video-android 0.1.3 → 0.1.4

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.
@@ -0,0 +1,8 @@
1
+ package dev.pictor.video
2
+
3
+ import androidx.media3.common.Player
4
+
5
+ /** Media3 seeks in milliseconds. A duplicate must not erase its latched frame. */
6
+ internal fun isRedundantCompositionSeek(positionMs: Long, targetUs: Long, state: Int): Boolean =
7
+ (state == Player.STATE_READY || state == Player.STATE_BUFFERING) &&
8
+ positionMs == targetUs / 1000
@@ -145,6 +145,7 @@ internal object CompositionSpike {
145
145
  val playbackState: Int,
146
146
  val durationUs: Long,
147
147
  val presentedUs: Long,
148
+ val presentedWindowIndex: Int,
148
149
  val presentedAgeNs: Long,
149
150
  /** How long the read itself took, which bounds the pairing error. */
150
151
  val readSpanNs: Long,
@@ -606,6 +607,7 @@ internal object CompositionSpike {
606
607
  playbackState = playbackState,
607
608
  durationUs = duration,
608
609
  presentedUs = frame?.get(FrameClock.Field.PTS_US) ?: -1,
610
+ presentedWindowIndex = frame?.get(FrameClock.Field.WINDOW_INDEX)?.toInt() ?: -1,
609
611
  /*
610
612
  Aged from the *scheduled release*, not from the metadata callback.
611
613
 
@@ -699,6 +701,7 @@ internal object CompositionSpike {
699
701
  .put("playbackState", snapshot.playbackState)
700
702
  .put("durationUs", snapshot.durationUs)
701
703
  .put("presentedUs", if (snapshot.presentedUs < 0) JSONObject.NULL else snapshot.presentedUs)
704
+ .put("presentedWindowIndex", snapshot.presentedWindowIndex)
702
705
  .put("presentedAgeNs", snapshot.presentedAgeNs)
703
706
  .put("readSpanNs", snapshot.readSpanNs)
704
707
  // How old this pairing is by the time it is read. Reported so a
@@ -761,11 +764,14 @@ internal object CompositionSpike {
761
764
  nothing follows it to prepare.
762
765
  */
763
766
  val seams = ArrayList<Long>(itemsJson.length())
767
+ val windowEndsUs = LongArray(itemsJson.length())
764
768
  var at = 0L
765
769
  for (i in 0 until itemsJson.length()) {
766
770
  val item = itemsJson.getJSONObject(i)
767
771
  val speed = item.optDouble("speed", 1.0).takeIf { it > 0 } ?: 1.0
768
- at += ((item.optLong("endUs") - item.optLong("startUs")) / speed).toLong()
772
+ at = item.optLong("timelineEndUs",
773
+ at + ((item.optLong("endUs") - item.optLong("startUs")) / speed).toLong())
774
+ windowEndsUs[i] = at
769
775
  if (i < itemsJson.length() - 1) seams.add(at)
770
776
  }
771
777
  seamHoldEnabled = spec.optBoolean("seamHold", false)
@@ -804,7 +810,10 @@ internal object CompositionSpike {
804
810
  // The same hook the two-player path uses, so the presented
805
811
  // frame is measured by the machinery already trusted.
806
812
  p.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
807
- FrameClock.offer(presentationTimeUs, releaseTimeNs)
813
+ FrameClock.offerForWindow(
814
+ presentationTimeUs, releaseTimeNs,
815
+ compositionWindowAt(presentationTimeUs, windowEndsUs),
816
+ )
808
817
  }
809
818
  p.addListener(object : Player.Listener {
810
819
  override fun onPlaybackStateChanged(state: Int) {
@@ -873,17 +882,8 @@ internal object CompositionSpike {
873
882
  .build(),
874
883
  )
875
884
  .build()
876
- val builder = EditedMediaItem.Builder(media)
877
- /*
878
- No clip audio, ever.
879
-
880
- The prepared mix already contains it — that is what makes it a
881
- mix — and letting the clip's own track through would play the
882
- whole timeline twice. This is the same rule the two-player path
883
- enforces by muting the source player.
884
- */
885
- .setRemoveAudio(true)
886
- .setDurationUs(endUs - startUs)
885
+ // The helper removes clip audio: the prepared mix already contains it.
886
+ val builder = compositionVideoItem(media, o.optLong("sourceDurationUs", endUs))
887
887
  if (speed != 1f) {
888
888
  /*
889
889
  Speed as a source mapping, not as a clock rate.
@@ -929,6 +929,16 @@ internal object CompositionSpike {
929
929
  // A seek supersedes any hold: the boundary it was covering is no longer
930
930
  // where playback is going.
931
931
  cancelSeamHold()
932
+ // Scrubbing sends the last position again on release. Media3 may not
933
+ // emit another frame for a no-op seek; bumping FrameClock here would
934
+ // erase the only frame that can satisfy the following Play readiness
935
+ // gate. Keep pause/hold semantics, but don't invalidate that frame or
936
+ // the metadata for a seek to this position already in flight.
937
+ if (isRedundantCompositionSeek(it.currentPosition, positionUs, it.playbackState)) {
938
+ it.pause()
939
+ armRelease(positionUs)
940
+ return@onPlayer
941
+ }
932
942
  seekStartedNs = System.nanoTime()
933
943
  seekReadyNs = 0
934
944
  // A new epoch: frames still in flight belong to the position being left.
@@ -0,0 +1,16 @@
1
+ package dev.pictor.video
2
+
3
+ import androidx.media3.common.MediaItem
4
+ import androidx.media3.transformer.EditedMediaItem
5
+
6
+ /**
7
+ * Media3 applies the MediaItem's trim itself. Its duration is the untrimmed
8
+ * source duration, NOT end - start: that would apply the trim twice and reject
9
+ * any end beyond that shortened duration. Speed is applied separately as well.
10
+ * Legacy callers without metadata supply endUs as the known source extent;
11
+ * with an explicit clipping end this gives the same presentation duration.
12
+ */
13
+ internal fun compositionVideoItem(media: MediaItem, sourceDurationUs: Long): EditedMediaItem.Builder =
14
+ EditedMediaItem.Builder(media)
15
+ .setRemoveAudio(true)
16
+ .setDurationUs(sourceDurationUs)
@@ -0,0 +1,17 @@
1
+ package dev.pictor.video
2
+
3
+ /**
4
+ * CompositionPlayer emits project-time PTS, not playlist transition events.
5
+ * Tag the actual offered frame using compiled exclusive window ends, never
6
+ * the playhead: outgoing and incoming frames can be in flight at the seam.
7
+ */
8
+ internal fun compositionWindowAt(presentationUs: Long, windowEndsUs: LongArray): Int {
9
+ if (presentationUs < 0 || windowEndsUs.isEmpty()) return -1
10
+ var low = 0
11
+ var high = windowEndsUs.size
12
+ while (low < high) {
13
+ val mid = (low + high) ushr 1
14
+ if (presentationUs < windowEndsUs[mid]) high = mid else low = mid + 1
15
+ }
16
+ return low.coerceAtMost(windowEndsUs.lastIndex)
17
+ }
@@ -212,6 +212,11 @@ internal object FrameClock {
212
212
  /** From `VideoFrameMetadataListener`, before the frame is released. */
213
213
  @JvmStatic
214
214
  fun offer(ptsUs: Long, scheduledReleaseNs: Long) {
215
+ offerForWindow(ptsUs, scheduledReleaseNs, windowIndex)
216
+ }
217
+
218
+ /** Composition frames carry their own window, determined from their PTS. */
219
+ internal fun offerForWindow(ptsUs: Long, scheduledReleaseNs: Long, frameWindowIndex: Int) {
215
220
  val now = PlaybackAnchor.nowNs()
216
221
  /*
217
222
  How far ahead the renderer scheduled this frame.
@@ -239,7 +244,7 @@ internal object FrameClock {
239
244
  sample[Field.POSITION_AT_RELEASE_US] = PlaybackAnchor.positionAt(scheduledReleaseNs)
240
245
  sample[Field.FLAGS] = stateBits(anchor)
241
246
  sample[Field.ANCHOR_AGE_NS] = PlaybackAnchor.ageNs(scheduledReleaseNs)
242
- sample[Field.WINDOW_INDEX] = windowIndex.toLong()
247
+ sample[Field.WINDOW_INDEX] = frameWindowIndex.toLong()
243
248
  synchronized(lock) {
244
249
  if (metadata.size >= CAPACITY) {
245
250
  metadata.removeFirst()
package/dist/index.d.mts CHANGED
@@ -2431,6 +2431,10 @@ interface CompositionSpikeItem {
2431
2431
  /** Clipping bounds in **source** time, which is where speed is applied. */
2432
2432
  startUs: number;
2433
2433
  endUs: number;
2434
+ /** Full source duration, before trimming or speed. Omit only for legacy callers. */
2435
+ sourceDurationUs?: number;
2436
+ /** Exclusive project-time end from the playlist compiler, after trim and speed. */
2437
+ timelineEndUs?: number;
2434
2438
  speed: number;
2435
2439
  }
2436
2440
  /** @experimental */
@@ -2517,6 +2521,8 @@ interface CompositionSpikeState {
2517
2521
  playbackState?: number;
2518
2522
  durationUs?: number;
2519
2523
  presentedUs?: number | null;
2524
+ /** Playlist window belonging to the latched frame, not the current playhead. */
2525
+ presentedWindowIndex?: number;
2520
2526
  /** How old the presented frame was when it was read, so a stalled sample can be excluded. */
2521
2527
  presentedAgeNs?: number;
2522
2528
  /** How long the paired read itself took. Bounds the pairing error. */
package/dist/index.d.ts CHANGED
@@ -2431,6 +2431,10 @@ interface CompositionSpikeItem {
2431
2431
  /** Clipping bounds in **source** time, which is where speed is applied. */
2432
2432
  startUs: number;
2433
2433
  endUs: number;
2434
+ /** Full source duration, before trimming or speed. Omit only for legacy callers. */
2435
+ sourceDurationUs?: number;
2436
+ /** Exclusive project-time end from the playlist compiler, after trim and speed. */
2437
+ timelineEndUs?: number;
2434
2438
  speed: number;
2435
2439
  }
2436
2440
  /** @experimental */
@@ -2517,6 +2521,8 @@ interface CompositionSpikeState {
2517
2521
  playbackState?: number;
2518
2522
  durationUs?: number;
2519
2523
  presentedUs?: number | null;
2524
+ /** Playlist window belonging to the latched frame, not the current playhead. */
2525
+ presentedWindowIndex?: number;
2520
2526
  /** How old the presented frame was when it was read, so a stalled sample can be excluded. */
2521
2527
  presentedAgeNs?: number;
2522
2528
  /** How long the paired read itself took. Bounds the pairing error. */
package/dist/index.js CHANGED
@@ -722,6 +722,7 @@ function compositionSpikeSpec(project, mix, resolve) {
722
722
  const playlist = (0, import_video_core.playlistOf)(project);
723
723
  const items = [];
724
724
  const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`);
725
+ const clips = new Map(project.clips.map((clip) => [clip.id, clip]));
725
726
  for (const item of playlist.items) {
726
727
  const uri = resolve(item.source);
727
728
  if (uri === null) {
@@ -732,6 +733,8 @@ function compositionSpikeSpec(project, mix, resolve) {
732
733
  uri,
733
734
  startUs: Number(item.startUs),
734
735
  endUs: Number(item.endUs),
736
+ sourceDurationUs: clips.get(item.clipId)?.metadata.duration,
737
+ timelineEndUs: Number(item.timelineEndUs),
735
738
  speed: item.speed
736
739
  });
737
740
  }