@pictor-sdk/video-android 0.1.3 → 0.1.5
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/android/src/main/java/dev/pictor/video/CompositionSeek.kt +8 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +23 -13
- package/android/src/main/java/dev/pictor/video/CompositionVideoItem.kt +16 -0
- package/android/src/main/java/dev/pictor/video/CompositionWindow.kt +17 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +6 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/compositionSpike.ts +9 -0
- package/src/useCompositionPlayback.ts +55 -1
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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] =
|
|
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
|
@@ -714,6 +714,7 @@ function useVideoSurface(options) {
|
|
|
714
714
|
|
|
715
715
|
// src/useCompositionPlayback.ts
|
|
716
716
|
var import_react2 = require("react");
|
|
717
|
+
var import_react_native3 = require("react-native");
|
|
717
718
|
var import_video_core2 = require("@pictor-sdk/video-core");
|
|
718
719
|
|
|
719
720
|
// src/compositionSpike.ts
|
|
@@ -722,6 +723,7 @@ function compositionSpikeSpec(project, mix, resolve) {
|
|
|
722
723
|
const playlist = (0, import_video_core.playlistOf)(project);
|
|
723
724
|
const items = [];
|
|
724
725
|
const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`);
|
|
726
|
+
const clips = new Map(project.clips.map((clip) => [clip.id, clip]));
|
|
725
727
|
for (const item of playlist.items) {
|
|
726
728
|
const uri = resolve(item.source);
|
|
727
729
|
if (uri === null) {
|
|
@@ -732,6 +734,8 @@ function compositionSpikeSpec(project, mix, resolve) {
|
|
|
732
734
|
uri,
|
|
733
735
|
startUs: Number(item.startUs),
|
|
734
736
|
endUs: Number(item.endUs),
|
|
737
|
+
sourceDurationUs: clips.get(item.clipId)?.metadata.duration,
|
|
738
|
+
timelineEndUs: Number(item.timelineEndUs),
|
|
735
739
|
speed: item.speed
|
|
736
740
|
});
|
|
737
741
|
}
|
|
@@ -928,10 +932,26 @@ function useCompositionPlayback(options) {
|
|
|
928
932
|
setUnsupported(
|
|
929
933
|
refused.map((reason) => ({ clipId: reason.split(":")[0] ?? "", reason }))
|
|
930
934
|
);
|
|
931
|
-
if (spec.items.length === 0)
|
|
935
|
+
if (spec.items.length === 0) {
|
|
936
|
+
spikePause();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
932
939
|
const started = spikeStart(spec);
|
|
933
940
|
if (started.ok === true) loaded.current = signature;
|
|
934
941
|
}, [enabled, signature, surfaceGeneration, project, mix]);
|
|
942
|
+
(0, import_react2.useEffect)(() => {
|
|
943
|
+
const subscription = import_react_native3.AppState.addEventListener("change", (next) => {
|
|
944
|
+
if (next === "active") {
|
|
945
|
+
lifecycle.resume();
|
|
946
|
+
setLifecycleEpoch(lifecycle.epoch);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
lifecycle.suspend("background");
|
|
950
|
+
setLifecycleEpoch(lifecycle.epoch);
|
|
951
|
+
setPlaying(false);
|
|
952
|
+
});
|
|
953
|
+
return () => subscription.remove();
|
|
954
|
+
}, [lifecycle]);
|
|
935
955
|
(0, import_react2.useEffect)(() => {
|
|
936
956
|
return () => {
|
|
937
957
|
lifecycle.teardown("unmount");
|
|
@@ -1016,7 +1036,7 @@ function useCompositionPlayback(options) {
|
|
|
1016
1036
|
// src/useMedia3Playback.ts
|
|
1017
1037
|
var import_video_core3 = require("@pictor-sdk/video-core");
|
|
1018
1038
|
var import_react3 = require("react");
|
|
1019
|
-
var
|
|
1039
|
+
var import_react_native4 = require("react-native");
|
|
1020
1040
|
|
|
1021
1041
|
// src/surfaceRecovery.ts
|
|
1022
1042
|
var RECOVERY_GRACE_MS = 1500;
|
|
@@ -1108,7 +1128,7 @@ function useMedia3Playback(options) {
|
|
|
1108
1128
|
const lifecycle = lifecycleRef.current;
|
|
1109
1129
|
const [lifecycleEpoch, setLifecycleEpoch] = (0, import_react3.useState)(lifecycle.epoch);
|
|
1110
1130
|
(0, import_react3.useEffect)(() => {
|
|
1111
|
-
const subscription =
|
|
1131
|
+
const subscription = import_react_native4.AppState.addEventListener("change", (next) => {
|
|
1112
1132
|
if (next === "active") {
|
|
1113
1133
|
lifecycle.resume();
|
|
1114
1134
|
setLifecycleEpoch(lifecycle.epoch);
|