@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,978 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import androidx.media3.common.MediaItem
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.common.audio.SpeedProvider
|
|
9
|
+
import androidx.media3.common.util.Size
|
|
10
|
+
import androidx.media3.transformer.Composition
|
|
11
|
+
import androidx.media3.transformer.CompositionPlayer
|
|
12
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
13
|
+
import androidx.media3.transformer.EditedMediaItemSequence
|
|
14
|
+
import androidx.media3.transformer.Effects
|
|
15
|
+
import org.json.JSONArray
|
|
16
|
+
import org.json.JSONObject
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One playback clock for the picture and the prepared mix.
|
|
20
|
+
*
|
|
21
|
+
* ## Why this exists
|
|
22
|
+
*
|
|
23
|
+
* The two-player preview cannot meet the 60 ms gate. Attribution said why, and
|
|
24
|
+
* none of it is tuneable: the two transports are compared through anchors
|
|
25
|
+
* polled at 50 ms, so a single observation carries up to ±56 ms of uncertainty
|
|
26
|
+
* against a 60 ms tolerance; the correction loop runs at 500 ms and each
|
|
27
|
+
* corrective seek costs 200-400 ms, so the loop's own latency exceeds what it
|
|
28
|
+
* is holding; and at a seam the project clock divides the local position by a
|
|
29
|
+
* speed the player has not applied for about 450 ms.
|
|
30
|
+
*
|
|
31
|
+
* ## The contract that makes this possible
|
|
32
|
+
*
|
|
33
|
+
* Verified against media3 1.9.4 as installed, not against a summary of it:
|
|
34
|
+
*
|
|
35
|
+
* - `Renderer.render(positionUs, ...)` takes **renderer time** — "a
|
|
36
|
+
* continuously increasing value across all input sample streams" — so a
|
|
37
|
+
* project-wide audio stream has a coherent axis to sit on.
|
|
38
|
+
* - `DefaultMediaClock` allows exactly one renderer to provide the clock
|
|
39
|
+
* ("Multiple renderer media clocks enabled" is an error), and
|
|
40
|
+
* `setPlaybackParameters` asks that clock first and takes back whatever it
|
|
41
|
+
* accepts. So the clock owner really is authoritative, and a clip's speed
|
|
42
|
+
* cannot be expressed as a global `PlaybackParameters` without dragging the
|
|
43
|
+
* mix along with it.
|
|
44
|
+
* - Therefore the per-clip speed has to live in the *source* mapping, not in
|
|
45
|
+
* the clock. `Effects.createExperimentalSpeedChangingEffect` returns exactly
|
|
46
|
+
* that pair — an audio processor and a video timestamp adjustment — and
|
|
47
|
+
* `CompositionPlayer` is documented to accept speed effects only from it.
|
|
48
|
+
*
|
|
49
|
+
* `CompositionPlayer` builds one `ExoPlayer` per sequence but hands them a
|
|
50
|
+
* single `Clock`, one playback thread, and one `PlaybackAudioGraphWrapper` that
|
|
51
|
+
* mixes every sequence into one sink. That is the difference from the current
|
|
52
|
+
* design: not "two clocks compared often" but one clock, with the picture timed
|
|
53
|
+
* against the audio that is actually being played.
|
|
54
|
+
*
|
|
55
|
+
* ## What this deliberately is not
|
|
56
|
+
*
|
|
57
|
+
* A spike, behind [Diagnostics]. It does not replace the preview, it does not
|
|
58
|
+
* touch preparation or export, and it claims nothing about live PCM gain or
|
|
59
|
+
* fades — those are a separate capability and the mix it plays is the same
|
|
60
|
+
* prepared file the current preview uses.
|
|
61
|
+
*
|
|
62
|
+
* Every timing value comes from the shared compiler: the host passes
|
|
63
|
+
* `playlistOf()`'s items verbatim and this maps them onto media items. There is
|
|
64
|
+
* no second interpretation of the project here, which is the invariant that
|
|
65
|
+
* made the relaxation in the first place tolerable.
|
|
66
|
+
*/
|
|
67
|
+
/*
|
|
68
|
+
The preview's transport, and no longer only a spike.
|
|
69
|
+
|
|
70
|
+
Built as one, measured as one, and accepted for v1 by watching it: the object
|
|
71
|
+
keeps its name because the native bridge method names are generated from the
|
|
72
|
+
TurboModule spec and renaming them is churn without a reader — the editor
|
|
73
|
+
reaches this through `useCompositionPlayback` and never sees the word. The
|
|
74
|
+
rename is a tracked follow-up.
|
|
75
|
+
|
|
76
|
+
Transport and ownership — `start`, `claim`, `play`, `pause`, `seek`,
|
|
77
|
+
`release` — are production paths and are no longer gated on diagnostics. The
|
|
78
|
+
measurement surface still is: `drain` returns nothing without diagnostics,
|
|
79
|
+
because a release build has no reason to retain per-frame samples.
|
|
80
|
+
*/
|
|
81
|
+
internal object CompositionSpike {
|
|
82
|
+
|
|
83
|
+
/** A constant speed for one clip. The compiler decided it; this carries it. */
|
|
84
|
+
private class ConstantSpeed(private val speed: Float) : SpeedProvider {
|
|
85
|
+
override fun getSpeed(timeUs: Long): Float = speed
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
No further changes: a clip's speed is constant across the clip.
|
|
89
|
+
|
|
90
|
+
`C.TIME_UNSET` and not `Long.MIN_VALUE` — they differ by one and the
|
|
91
|
+
difference is not cosmetic. `SpeedProviderUtil` walks the speed
|
|
92
|
+
changes to compute the item's presentation duration, and
|
|
93
|
+
`Long.MIN_VALUE` failed its `checkState` with a bare
|
|
94
|
+
`IllegalStateException` naming nothing.
|
|
95
|
+
*/
|
|
96
|
+
override fun getNextSpeedChangeTimeUs(timeUs: Long): Long =
|
|
97
|
+
androidx.media3.common.C.TIME_UNSET
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private val main = Handler(Looper.getMainLooper())
|
|
101
|
+
|
|
102
|
+
@Volatile private var player: CompositionPlayer? = null
|
|
103
|
+
@Volatile private var lastError: String? = null
|
|
104
|
+
@Volatile private var startedAtNs = 0L
|
|
105
|
+
@Volatile private var readyAtNs = 0L
|
|
106
|
+
@Volatile private var seekStartedNs = 0L
|
|
107
|
+
@Volatile private var seekReadyNs = 0L
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One reading of both clocks, taken together on the player's own thread.
|
|
111
|
+
*
|
|
112
|
+
* `CompositionPlayer` must be accessed from a single application thread —
|
|
113
|
+
* reading its position from the JS thread throws "Player is accessed on the
|
|
114
|
+
* wrong thread" — so the pairing is made here and published as a snapshot.
|
|
115
|
+
*
|
|
116
|
+
* This is polling, and it is not the polling the old design failed on.
|
|
117
|
+
* There, two independently-pumped anchors were subtracted, so the error was
|
|
118
|
+
* the sum of two staleness windows and could reach ±56 ms. Here both values
|
|
119
|
+
* are read in the same statement on the same thread, so the *pairing* is
|
|
120
|
+
* exact; the interval only affects how fresh the answer is, and the frame's
|
|
121
|
+
* own age is carried so a stale one can be excluded rather than averaged.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Which presentation boundary this measures.
|
|
125
|
+
*
|
|
126
|
+
* Three are observable in-process and they are not the same instant:
|
|
127
|
+
*
|
|
128
|
+
* - `METADATA_NS` Media3 says a frame is about to be rendered.
|
|
129
|
+
* - `RELEASE_NS` the time it *asks* the surface to present at. An
|
|
130
|
+
* intention, not an acknowledgement.
|
|
131
|
+
* - `CONSUMED_NS` the consumer called `updateTexImage` and latched it.
|
|
132
|
+
*
|
|
133
|
+
* The offset is computed from `CONSUMED_NS`, because it is the latest
|
|
134
|
+
* boundary this process can witness rather than request. Actual scanout is
|
|
135
|
+
* one compositor pass later and is **not observed** here: there is no
|
|
136
|
+
* present fence or `onFrameCommit` in this path, so nothing in these
|
|
137
|
+
* numbers proves when the panel lit up. `RELEASE_NS` is retained beside it
|
|
138
|
+
* so the gap between intended and latched is visible rather than assumed
|
|
139
|
+
* to be zero.
|
|
140
|
+
*/
|
|
141
|
+
private class Pairing(
|
|
142
|
+
val atNs: Long,
|
|
143
|
+
val positionUs: Long,
|
|
144
|
+
val playing: Boolean,
|
|
145
|
+
val playbackState: Int,
|
|
146
|
+
val durationUs: Long,
|
|
147
|
+
val presentedUs: Long,
|
|
148
|
+
val presentedAgeNs: Long,
|
|
149
|
+
/** How long the read itself took, which bounds the pairing error. */
|
|
150
|
+
val readSpanNs: Long,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@Volatile private var pairing: Pairing? = null
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A transition epoch, bumped by start and every seek.
|
|
157
|
+
*
|
|
158
|
+
* A frame carried over from before a seek is not a late frame, it is a
|
|
159
|
+
* frame about a position nobody is at. Pairing one with the clock after the
|
|
160
|
+
* seek produces an excursion the size of the seek, and no amount of
|
|
161
|
+
* averaging tells that apart from a real one.
|
|
162
|
+
*/
|
|
163
|
+
@Volatile private var epoch = 0L
|
|
164
|
+
@Volatile private var epochAtNs = 0L
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The newest preparation anybody has claimed.
|
|
168
|
+
*
|
|
169
|
+
* A preparation that finishes after a newer one was asked for describes a
|
|
170
|
+
* document that no longer exists. Refusing it here, at the moment of
|
|
171
|
+
* attachment, is the only place that can be certain: the host cannot know
|
|
172
|
+
* whether a promise it is still holding was overtaken, and a mix that
|
|
173
|
+
* attaches for a beat before being replaced is a beat of the wrong sound.
|
|
174
|
+
*/
|
|
175
|
+
private val claimed = java.util.concurrent.atomic.AtomicLong(0)
|
|
176
|
+
|
|
177
|
+
/** Claim the next request id. The host does this before it starts preparing. */
|
|
178
|
+
fun claim(): String {
|
|
179
|
+
return JSONObject().put("ok", true).put("requestId", claimed.incrementAndGet()).toString()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Attachments refused because a newer request had already been claimed. */
|
|
183
|
+
@Volatile var superseded = 0L
|
|
184
|
+
private set
|
|
185
|
+
|
|
186
|
+
/*
|
|
187
|
+
Playback readiness, as transport behaviour rather than as a label.
|
|
188
|
+
|
|
189
|
+
Marking an interval "preparing" in a report while the audio keeps playing
|
|
190
|
+
describes the defect instead of fixing it. So the transport is actually
|
|
191
|
+
held: `intentPlaying` is what the person asked for, and the player is only
|
|
192
|
+
started once a frame belonging to the current epoch has been latched.
|
|
193
|
+
|
|
194
|
+
The decoder still runs while the hold is on — the player is paused, not
|
|
195
|
+
stopped, and Media3 renders the first frame after a seek even when it is
|
|
196
|
+
not playing. That is what keeps this from deadlocking: the condition the
|
|
197
|
+
hold waits for is produced by work the hold does not prevent.
|
|
198
|
+
*/
|
|
199
|
+
@Volatile var intentPlaying = false
|
|
200
|
+
private set
|
|
201
|
+
|
|
202
|
+
@Volatile private var releasePending = false
|
|
203
|
+
@Volatile private var releaseArmedNs = 0L
|
|
204
|
+
/** Where this epoch is meant to be, in composition time. */
|
|
205
|
+
@Volatile private var epochTargetUs = 0L
|
|
206
|
+
@Volatile private var releasedAtNs = 0L
|
|
207
|
+
@Volatile private var readinessFailure: String? = null
|
|
208
|
+
|
|
209
|
+
/** Holds that ended in a release, and holds that timed out. */
|
|
210
|
+
@Volatile var releases = 0L
|
|
211
|
+
private set
|
|
212
|
+
|
|
213
|
+
@Volatile var readinessTimeouts = 0L
|
|
214
|
+
private set
|
|
215
|
+
|
|
216
|
+
/** How long a hold may wait before it is reported as a failure. */
|
|
217
|
+
private const val READINESS_TIMEOUT_NS = 3_000_000_000L
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The clip boundaries, in composition time, and the hold that covers them.
|
|
221
|
+
*
|
|
222
|
+
* ## Why a hold at all
|
|
223
|
+
*
|
|
224
|
+
* Measured on a Pixel 7: at each boundary the video decoder is released and
|
|
225
|
+
* a new one allocated and initialised — 126 ms from `videoDecoderReleased`
|
|
226
|
+
* at mediaPos 9.90 to `videoDecoderInitialized` at 10.02 — and nothing
|
|
227
|
+
* reaches the renderer's output for 119 to 168 ms. `videoPrewarmingEnabled`
|
|
228
|
+
* is on by default, `createSecondaryRenderer` is gated on it, the internal
|
|
229
|
+
* ExoPlayer enables dynamic scheduling, and two decoders are created at
|
|
230
|
+
* startup, so prewarming is configured and running. It still does not carry
|
|
231
|
+
* this boundary, and the boundary has no format excuse: the two clips are
|
|
232
|
+
* both H.264 High, 1080x1920, yuv420p, level 40, 30 fps.
|
|
233
|
+
*
|
|
234
|
+
* So the clock waits instead of the picture falling behind it. **This is a
|
|
235
|
+
* playback pause, not seamless playback**, and its wall-clock duration is
|
|
236
|
+
* reported separately from synchronisation for exactly that reason.
|
|
237
|
+
*
|
|
238
|
+
* ## Armed before the boundary, not after it
|
|
239
|
+
*
|
|
240
|
+
* Noticing the missing frame afterwards is the reactive delay this
|
|
241
|
+
* replaces. The hold arms when the position crosses `seam - SEAM_ARM_LEAD`,
|
|
242
|
+
* which pauses the shared clock while the player goes on preparing.
|
|
243
|
+
*/
|
|
244
|
+
@Volatile private var seamTargetsUs: LongArray = LongArray(0)
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Off, because the hold does not work on this player.
|
|
248
|
+
*
|
|
249
|
+
* Measured twice, at both arming positions. Pausing at `seam - 250 ms` and
|
|
250
|
+
* pausing exactly on the boundary both ran to the 2 s bound and reported a
|
|
251
|
+
* timeout: with `playWhenReady` false `CompositionPlayer` does not advance
|
|
252
|
+
* across an item boundary, so the incoming frame the hold waits for is
|
|
253
|
+
* never produced. Holding the clock and waiting for the new stream are
|
|
254
|
+
* mutually exclusive here.
|
|
255
|
+
*
|
|
256
|
+
* The mechanism is kept, disabled, with its bound and its cancellation
|
|
257
|
+
* intact — the negative result is worth more than the deleted code. Set
|
|
258
|
+
* `"seamHold": true` in the spec to run it.
|
|
259
|
+
*
|
|
260
|
+
* **Diagnostics only, and not a candidate for production.** Both variants
|
|
261
|
+
* were measured and neither meets the gate: pausing alone deadlocks, and
|
|
262
|
+
* the seek variant prepares but leaves the run worse than doing nothing.
|
|
263
|
+
* It is preserved as evidence of what was tried, not as an alternative
|
|
264
|
+
* waiting for a different player.
|
|
265
|
+
*/
|
|
266
|
+
@Volatile private var seamHoldEnabled = false
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Prepare the incoming item with an explicit seek, rather than only pausing.
|
|
270
|
+
*
|
|
271
|
+
* Pausing natural traversal and explicitly preparing a seek target are not
|
|
272
|
+
* the same operation. `CompositionPlayer.handleSeek` calls
|
|
273
|
+
* `CompositionPlayerInternal.startSeek`, then `ExoPlayer.seekTo` on each
|
|
274
|
+
* sequence, then resets the packet release control and releases the
|
|
275
|
+
* aggregator's frames before `endSeek` — and `ExoPlayer.seekTo` resets the
|
|
276
|
+
* renderers' positions, which is the path that makes a paused seek produce
|
|
277
|
+
* a frame. It is the same path the existing seek readiness already uses and
|
|
278
|
+
* passes on.
|
|
279
|
+
*
|
|
280
|
+
* So the seam is held by seeking to the incoming item's own compiled start,
|
|
281
|
+
* which is where the clock already is: no timeline time is skipped, and the
|
|
282
|
+
* retained picture keeps the outgoing frame on screen while it happens.
|
|
283
|
+
*
|
|
284
|
+
* It works, and it is worse. Measured over both boundaries: two holds of
|
|
285
|
+
* 241 ms and 180 ms, no timeouts, released on verified incoming frames — so
|
|
286
|
+
* paused preparation is real. But the boundary's output gap grew from 156
|
|
287
|
+
* and 119 ms to 330 and 262 ms, the seek itself triggering further decoder
|
|
288
|
+
* initialisations, and the submission gate went from one excursion at 67 ms
|
|
289
|
+
* to two at 208.6 ms. The hold covers less of the gap than it adds.
|
|
290
|
+
*/
|
|
291
|
+
@Volatile private var seamSeekEnabled = false
|
|
292
|
+
|
|
293
|
+
/** Seams already held for, so a hold arms once per boundary per pass. */
|
|
294
|
+
private val seamsHeld = java.util.concurrent.ConcurrentHashMap<Long, Boolean>()
|
|
295
|
+
|
|
296
|
+
@Volatile private var seamHoldTargetUs = -1L
|
|
297
|
+
@Volatile private var seamHoldStartedNs = 0L
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The boundary this pass has committed to holding at, before reaching it.
|
|
301
|
+
*
|
|
302
|
+
* Two phases, because one does not work. Pausing at `seam - lead` was tried
|
|
303
|
+
* and deadlocked: with the clock stopped short of the boundary the player
|
|
304
|
+
* never crosses it, so the frame the hold is waiting for is never produced,
|
|
305
|
+
* and the hold ran to its 2 s bound and reported a timeout — which is the
|
|
306
|
+
* bound doing its job, and a negative result.
|
|
307
|
+
*
|
|
308
|
+
* So the decision is made early and the pause lands on the boundary. The
|
|
309
|
+
* transition starts, the decoder is torn down and rebuilt, and what waits
|
|
310
|
+
* is the clock rather than the picture's place in it.
|
|
311
|
+
*/
|
|
312
|
+
@Volatile private var seamArmedUs = -1L
|
|
313
|
+
|
|
314
|
+
/** Completed holds, as wall-clock milliseconds. Reported, never averaged in. */
|
|
315
|
+
private val seamHoldMs = java.util.concurrent.CopyOnWriteArrayList<Long>()
|
|
316
|
+
|
|
317
|
+
@Volatile var seamHoldTimeouts = 0L
|
|
318
|
+
private set
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* How far before a boundary the hold arms.
|
|
322
|
+
*
|
|
323
|
+
* Longer than the measured 168 ms worst gap, so the pause begins before the
|
|
324
|
+
* preparation it is covering rather than inside it.
|
|
325
|
+
*/
|
|
326
|
+
private const val SEAM_ARM_LEAD_US = 250_000L
|
|
327
|
+
|
|
328
|
+
/** A hold that has not resolved by here is reported, never released blind. */
|
|
329
|
+
private const val SEAM_HOLD_TIMEOUT_NS = 2_000_000_000L
|
|
330
|
+
|
|
331
|
+
/** Declare the boundaries this composition will cross. */
|
|
332
|
+
fun setSeamTargets(targets: LongArray) {
|
|
333
|
+
seamTargetsUs = targets
|
|
334
|
+
seamsHeld.clear()
|
|
335
|
+
seamHoldTargetUs = -1
|
|
336
|
+
seamArmedUs = -1
|
|
337
|
+
seamHoldMs.clear()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Drop any hold in flight. Pause, seek, background and teardown all do. */
|
|
341
|
+
private fun cancelSeamHold() {
|
|
342
|
+
seamHoldTargetUs = -1
|
|
343
|
+
seamArmedUs = -1
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Arm a hold for the epoch that just began.
|
|
348
|
+
*
|
|
349
|
+
* Start and seek both enter preparing. The intent is untouched: a hold is
|
|
350
|
+
* about *when* playback may run, never about whether the person asked for
|
|
351
|
+
* it.
|
|
352
|
+
*/
|
|
353
|
+
private fun armRelease(targetUs: Long) {
|
|
354
|
+
// Preparing is not streaming: the first frame for this position has to
|
|
355
|
+
// be latched as soon as it exists, not paced against a schedule that
|
|
356
|
+
// belongs to the position being left.
|
|
357
|
+
FrameClock.setStreaming(false)
|
|
358
|
+
releasePending = true
|
|
359
|
+
epochTargetUs = targetUs
|
|
360
|
+
releaseArmedNs = System.nanoTime()
|
|
361
|
+
releasedAtNs = 0
|
|
362
|
+
readinessFailure = null
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether a frame for the current epoch has actually been latched.
|
|
367
|
+
*
|
|
368
|
+
* Three things, not one. The frame must belong to this epoch — latched at
|
|
369
|
+
* or after the epoch began — it must carry a usable project timestamp, and
|
|
370
|
+
* it must have been latched rather than merely offered: `CONSUMED_NS` is
|
|
371
|
+
* set by the consumer after `updateTexImage` returned a new timestamp.
|
|
372
|
+
*/
|
|
373
|
+
private fun firstFrameReady(): Boolean {
|
|
374
|
+
val frame = FrameClock.latestSample() ?: return false
|
|
375
|
+
// Latched, not merely offered: the consumer sets this after
|
|
376
|
+
// `updateTexImage` returned a new timestamp.
|
|
377
|
+
if (frame[FrameClock.Field.CONSUMED_NS] <= 0L) return false
|
|
378
|
+
/*
|
|
379
|
+
The pixels and the timestamp must be the same frame.
|
|
380
|
+
|
|
381
|
+
Readiness releases transport on the claim "this frame is the one we
|
|
382
|
+
asked for", and a fallback pairing cannot support it: the timestamp is
|
|
383
|
+
the claimed metadata and the pixels are whatever was latched. Starting
|
|
384
|
+
on one is how playback showed frame 7 at frame 2's instant.
|
|
385
|
+
*/
|
|
386
|
+
if (frame[FrameClock.Field.ASSOCIATION] != 1L) return false
|
|
387
|
+
val pts = frame[FrameClock.Field.PTS_US]
|
|
388
|
+
if (pts < 0L) return false
|
|
389
|
+
/*
|
|
390
|
+
Matched by position, not by arrival order.
|
|
391
|
+
|
|
392
|
+
Requiring a frame latched *after* the button press deadlocked at
|
|
393
|
+
startup: the player renders its first frame when it becomes ready,
|
|
394
|
+
which is before anyone presses play, so the condition could never be
|
|
395
|
+
met and three of four transitions timed out at 3 s. What the gate
|
|
396
|
+
actually needs is a frame showing *this* epoch's position — the same
|
|
397
|
+
test for a start, a seek and a resume.
|
|
398
|
+
*/
|
|
399
|
+
/*
|
|
400
|
+
A seam hold wants the frame *after* the boundary, exactly.
|
|
401
|
+
|
|
402
|
+
The ordinary backward tolerance exists because a seek lands on the
|
|
403
|
+
preceding sample. At a seam the target is an exact known boundary, and
|
|
404
|
+
the outgoing clip's last frame sits one interval before it — inside
|
|
405
|
+
that tolerance. Releasing on it would end the hold on the picture the
|
|
406
|
+
hold exists to move past.
|
|
407
|
+
*/
|
|
408
|
+
if (seamHoldTargetUs >= 0) return pts >= seamHoldTargetUs
|
|
409
|
+
|
|
410
|
+
return pts >= epochTargetUs - FRAME_MATCH_BACK_US &&
|
|
411
|
+
pts <= epochTargetUs + FRAME_MATCH_AHEAD_US
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* How far a frame may sit from the epoch's target and still be its first.
|
|
416
|
+
*
|
|
417
|
+
* Backwards by about a frame, because a seek lands on the preceding
|
|
418
|
+
* keyframe-derived sample; forwards by a second, because the first frame
|
|
419
|
+
* after a seek into a long GOP can legitimately be later than asked for.
|
|
420
|
+
*/
|
|
421
|
+
private const val FRAME_MATCH_BACK_US = 40_000L
|
|
422
|
+
private const val FRAME_MATCH_AHEAD_US = 1_000_000L
|
|
423
|
+
|
|
424
|
+
/** Retained evidence, so an excursion can be looked at rather than inferred. */
|
|
425
|
+
private const val RING = 8192
|
|
426
|
+
private val ring = arrayOfNulls<LongArray>(RING)
|
|
427
|
+
private val written = java.util.concurrent.atomic.AtomicLong(0)
|
|
428
|
+
|
|
429
|
+
/** One retained sample. Packed: a gate reads thousands of these. */
|
|
430
|
+
private fun record(p: Pairing, frame: LongArray?, pipeline: LongArray) {
|
|
431
|
+
val seq = written.getAndIncrement()
|
|
432
|
+
val row = LongArray(19)
|
|
433
|
+
row[0] = seq
|
|
434
|
+
row[1] = p.atNs
|
|
435
|
+
row[2] = p.positionUs
|
|
436
|
+
row[3] = (if (p.playing) 1L else 0L) or (if (p.playbackState == Player.STATE_READY) 2L else 0L)
|
|
437
|
+
row[4] = epoch
|
|
438
|
+
row[5] = p.atNs - epochAtNs
|
|
439
|
+
row[6] = frame?.get(FrameClock.Field.ID) ?: -1
|
|
440
|
+
row[7] = frame?.get(FrameClock.Field.GENERATION) ?: -1
|
|
441
|
+
row[8] = frame?.get(FrameClock.Field.WINDOW_INDEX) ?: -1
|
|
442
|
+
row[9] = frame?.get(FrameClock.Field.PTS_US) ?: -1
|
|
443
|
+
row[10] = frame?.get(FrameClock.Field.RELEASE_NS) ?: -1
|
|
444
|
+
row[11] = frame?.get(FrameClock.Field.CONSUMED_NS) ?: -1
|
|
445
|
+
row[12] = frame?.get(FrameClock.Field.AVAILABLE_NS) ?: -1
|
|
446
|
+
row[13] = frame?.get(FrameClock.Field.COALESCED) ?: -1
|
|
447
|
+
row[14] = pipeline[0]
|
|
448
|
+
row[15] = pipeline[1]
|
|
449
|
+
row[16] = pipeline[2]
|
|
450
|
+
row[17] = p.readSpanNs
|
|
451
|
+
/*
|
|
452
|
+
The earliest boundary, and the one that decides the seam.
|
|
453
|
+
|
|
454
|
+
`METADATA_NS` is when Media3's renderer said a frame was about to go
|
|
455
|
+
to the surface. A gap that ends here produced nothing at all — the
|
|
456
|
+
decoder or the item's preparation. A gap between here and
|
|
457
|
+
`AVAILABLE_NS` is the video graph queueing it. Between `AVAILABLE_NS`
|
|
458
|
+
and `CONSUMED_NS` is this consumer. Three different owners, and
|
|
459
|
+
without this stamp the first two are one interval.
|
|
460
|
+
*/
|
|
461
|
+
row[18] = frame?.get(FrameClock.Field.METADATA_NS) ?: -1
|
|
462
|
+
ring[(seq % RING).toInt()] = row
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Retained samples from [fromSeq]. @return `{ok, rows, nextSeq, total, dropped}` */
|
|
466
|
+
fun drain(fromSeq: Long, limit: Int): String {
|
|
467
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
468
|
+
val total = written.get()
|
|
469
|
+
val oldest = maxOf(0L, total - RING)
|
|
470
|
+
// A gap is reported, never closed silently: a window that quietly lost
|
|
471
|
+
// its middle reads exactly like one that had nothing there.
|
|
472
|
+
val dropped = maxOf(0L, oldest - fromSeq)
|
|
473
|
+
var seq = maxOf(fromSeq, oldest)
|
|
474
|
+
val out = JSONArray()
|
|
475
|
+
while (seq < total && out.length() < limit) {
|
|
476
|
+
ring[(seq % RING).toInt()]?.let { row ->
|
|
477
|
+
if (row[0] == seq) out.put(JSONArray().also { a -> row.forEach { a.put(it) } })
|
|
478
|
+
}
|
|
479
|
+
seq += 1
|
|
480
|
+
}
|
|
481
|
+
return JSONObject()
|
|
482
|
+
.put("ok", true).put("rows", out).put("nextSeq", seq)
|
|
483
|
+
.put("total", total).put("dropped", dropped)
|
|
484
|
+
.toString()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private val pump = object : Runnable {
|
|
488
|
+
override fun run() {
|
|
489
|
+
val p = player ?: return
|
|
490
|
+
/*
|
|
491
|
+
The release decision runs here, on the player's own thread.
|
|
492
|
+
|
|
493
|
+
Same place the clocks are read, so the condition and the action
|
|
494
|
+
cannot be separated by a thread hop — and a newer seek that
|
|
495
|
+
re-arms between them would be acting on a hold that no longer
|
|
496
|
+
exists.
|
|
497
|
+
*/
|
|
498
|
+
/*
|
|
499
|
+
Arm the seam hold before the boundary, on the player's own thread.
|
|
500
|
+
|
|
501
|
+
Same thread that reads the clock and issues the transport call, so
|
|
502
|
+
the crossing and the pause cannot be separated by a hop. A hold
|
|
503
|
+
already in flight, or a transport the person has paused, arms
|
|
504
|
+
nothing.
|
|
505
|
+
*/
|
|
506
|
+
if (seamHoldEnabled && seamHoldTargetUs < 0 && releasePending.not() && intentPlaying && p.isPlaying) {
|
|
507
|
+
val positionUs = p.currentPosition * 1000
|
|
508
|
+
if (seamArmedUs < 0) {
|
|
509
|
+
for (seam in seamTargetsUs) {
|
|
510
|
+
if (seamsHeld.containsKey(seam)) continue
|
|
511
|
+
if (positionUs < seam - SEAM_ARM_LEAD_US || positionUs >= seam) continue
|
|
512
|
+
seamsHeld[seam] = true
|
|
513
|
+
seamArmedUs = seam
|
|
514
|
+
PreviewTrace.record("spike.seamArm") {
|
|
515
|
+
JSONObject().put("seamUs", seam).put("atUs", positionUs)
|
|
516
|
+
}
|
|
517
|
+
break
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (seamArmedUs >= 0 && positionUs >= seamArmedUs) {
|
|
521
|
+
/*
|
|
522
|
+
On the boundary, not before it. The clock stops here and
|
|
523
|
+
the outgoing picture stays on screen because nothing
|
|
524
|
+
replaces it; the transition is already under way.
|
|
525
|
+
*/
|
|
526
|
+
seamHoldTargetUs = seamArmedUs
|
|
527
|
+
seamHoldStartedNs = System.nanoTime()
|
|
528
|
+
seamArmedUs = -1
|
|
529
|
+
runCatching { p.pause() }
|
|
530
|
+
armRelease(seamHoldTargetUs)
|
|
531
|
+
if (seamSeekEnabled) {
|
|
532
|
+
/*
|
|
533
|
+
The same seek the readiness gate already survives, to
|
|
534
|
+
the position the clock is already at. Frames in flight
|
|
535
|
+
belong to the outgoing item, so the epoch moves with
|
|
536
|
+
it; the picture on screen is the ring's retained one
|
|
537
|
+
and is not disturbed.
|
|
538
|
+
*/
|
|
539
|
+
epoch += 1
|
|
540
|
+
epochAtNs = System.nanoTime()
|
|
541
|
+
FrameClock.bump()
|
|
542
|
+
runCatching { p.seekTo(seamHoldTargetUs / 1000) }
|
|
543
|
+
}
|
|
544
|
+
PreviewTrace.record("spike.seamHold") {
|
|
545
|
+
JSONObject().put("seamUs", seamHoldTargetUs).put("atUs", positionUs)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (releasePending && intentPlaying) {
|
|
550
|
+
val waitedNs = System.nanoTime() - releaseArmedNs
|
|
551
|
+
val holding = seamHoldTargetUs >= 0
|
|
552
|
+
val limitNs = if (holding) SEAM_HOLD_TIMEOUT_NS else READINESS_TIMEOUT_NS
|
|
553
|
+
if (firstFrameReady()) {
|
|
554
|
+
if (holding) {
|
|
555
|
+
seamHoldMs.add((System.nanoTime() - seamHoldStartedNs) / 1_000_000)
|
|
556
|
+
seamHoldTargetUs = -1
|
|
557
|
+
}
|
|
558
|
+
releasePending = false
|
|
559
|
+
releasedAtNs = System.nanoTime()
|
|
560
|
+
releases += 1
|
|
561
|
+
// Playback is continuous from here, across seams included.
|
|
562
|
+
FrameClock.setStreaming(true)
|
|
563
|
+
// Retention makes enforcement affordable: the acquisition is
|
|
564
|
+
// never deferred, so honouring the schedule no longer costs
|
|
565
|
+
// a frame per wait.
|
|
566
|
+
FrameClock.enforcePresentationSchedule = true
|
|
567
|
+
PreviewTrace.record("spike.release") {
|
|
568
|
+
JSONObject().put("waitedMs", waitedNs / 1_000_000)
|
|
569
|
+
}
|
|
570
|
+
runCatching { p.play() }
|
|
571
|
+
} else if (waitedNs > limitNs) {
|
|
572
|
+
if (holding) {
|
|
573
|
+
/*
|
|
574
|
+
A hold that never resolves is a defect, and resuming
|
|
575
|
+
blind would hide it behind the excursion it causes.
|
|
576
|
+
Reported, and the transport is left for the person to
|
|
577
|
+
restart rather than started on an unverified frame.
|
|
578
|
+
*/
|
|
579
|
+
seamHoldTimeouts += 1
|
|
580
|
+
seamHoldMs.add((System.nanoTime() - seamHoldStartedNs) / 1_000_000)
|
|
581
|
+
seamHoldTargetUs = -1
|
|
582
|
+
}
|
|
583
|
+
// Bounded, and reported rather than released blind: a hold
|
|
584
|
+
// that never resolves is a defect, and starting anyway
|
|
585
|
+
// would hide it behind the excursion it causes.
|
|
586
|
+
releasePending = false
|
|
587
|
+
readinessTimeouts += 1
|
|
588
|
+
readinessFailure = "no first frame within ${waitedNs / 1_000_000} ms"
|
|
589
|
+
PreviewTrace.record("spike.readinessTimeout") {
|
|
590
|
+
JSONObject().put("waitedMs", waitedNs / 1_000_000)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
val before = System.nanoTime()
|
|
595
|
+
val position = p.currentPosition * 1000
|
|
596
|
+
val playing = p.isPlaying
|
|
597
|
+
val playbackState = p.playbackState
|
|
598
|
+
val duration = p.duration * 1000
|
|
599
|
+
val frame = FrameClock.latestSample()
|
|
600
|
+
val pipeline = FrameClock.pipeline()
|
|
601
|
+
val after = System.nanoTime()
|
|
602
|
+
val paired = Pairing(
|
|
603
|
+
atNs = after,
|
|
604
|
+
positionUs = position,
|
|
605
|
+
playing = playing,
|
|
606
|
+
playbackState = playbackState,
|
|
607
|
+
durationUs = duration,
|
|
608
|
+
presentedUs = frame?.get(FrameClock.Field.PTS_US) ?: -1,
|
|
609
|
+
/*
|
|
610
|
+
Aged from the *scheduled release*, not from the metadata callback.
|
|
611
|
+
|
|
612
|
+
`offer` is recorded when Media3 says a frame is about to be
|
|
613
|
+
rendered; the frame is actually presented at the release time
|
|
614
|
+
it hands the surface, which was measured at about 40 ms later.
|
|
615
|
+
Ageing from the callback overstates it by that much, and the
|
|
616
|
+
overstatement lands directly on the offset as a constant bias.
|
|
617
|
+
*/
|
|
618
|
+
/*
|
|
619
|
+
Aged from the latch, the last boundary this process witnesses.
|
|
620
|
+
|
|
621
|
+
Not from `RELEASE_NS`: that is the time Media3 *asked* the
|
|
622
|
+
surface to present at, and an intended time is not an
|
|
623
|
+
acknowledgement. Both are retained, so the difference between
|
|
624
|
+
what was asked for and what was latched is measurable instead
|
|
625
|
+
of assumed away.
|
|
626
|
+
*/
|
|
627
|
+
presentedAgeNs =
|
|
628
|
+
if (frame == null) -1 else after - frame[FrameClock.Field.CONSUMED_NS],
|
|
629
|
+
readSpanNs = after - before,
|
|
630
|
+
)
|
|
631
|
+
pairing = paired
|
|
632
|
+
record(paired, frame, pipeline)
|
|
633
|
+
// A frame's worth. Fine grained because it costs one main-thread
|
|
634
|
+
// read of already-computed values, and because the gate has to
|
|
635
|
+
// resolve a transient that lasts a second and a half.
|
|
636
|
+
main.postDelayed(this, 16)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Players built and released, so a teardown gate can bound them. */
|
|
641
|
+
@Volatile var built = 0L
|
|
642
|
+
private set
|
|
643
|
+
|
|
644
|
+
@Volatile var releasedCount = 0L
|
|
645
|
+
private set
|
|
646
|
+
|
|
647
|
+
fun state(): String {
|
|
648
|
+
val p = player
|
|
649
|
+
val json = JSONObject()
|
|
650
|
+
.put("ok", true)
|
|
651
|
+
.put("attached", p != null)
|
|
652
|
+
.put("built", built)
|
|
653
|
+
.put("released", releasedCount)
|
|
654
|
+
.put("superseded", superseded)
|
|
655
|
+
.put("claimed", claimed.get())
|
|
656
|
+
.put("intentPlaying", intentPlaying)
|
|
657
|
+
.put("releasePending", releasePending)
|
|
658
|
+
.put("releases", releases)
|
|
659
|
+
.put("readinessTimeouts", readinessTimeouts)
|
|
660
|
+
.put("readinessFailure", readinessFailure ?: JSONObject.NULL)
|
|
661
|
+
/*
|
|
662
|
+
Hold durations, listed rather than summarised.
|
|
663
|
+
|
|
664
|
+
Each is a wall-clock pause the person sees. An average would make
|
|
665
|
+
two 150 ms stalls read like steady playback, and none of this is
|
|
666
|
+
synchronisation — it is the price paid to keep synchronisation.
|
|
667
|
+
*/
|
|
668
|
+
.put("seamHoldMs", org.json.JSONArray(seamHoldMs.toList()))
|
|
669
|
+
.put("seamHoldTimeouts", seamHoldTimeouts)
|
|
670
|
+
.put("seamHoldActive", seamHoldTargetUs >= 0)
|
|
671
|
+
.put("epochTargetUs", epochTargetUs)
|
|
672
|
+
.put(
|
|
673
|
+
"holdMs",
|
|
674
|
+
if (releasedAtNs > releaseArmedNs) (releasedAtNs - releaseArmedNs) / 1_000_000
|
|
675
|
+
else -1,
|
|
676
|
+
)
|
|
677
|
+
.put("error", lastError ?: JSONObject.NULL)
|
|
678
|
+
// Readiness is reported apart from synchronisation on purpose: a
|
|
679
|
+
// preparation pause is a latency figure, not an A/V error, and
|
|
680
|
+
// averaging one into the other is how a slow start reads as drift.
|
|
681
|
+
.put("startupMs", if (readyAtNs > startedAtNs) (readyAtNs - startedAtNs) / 1_000_000 else -1)
|
|
682
|
+
.put("seekMs", if (seekReadyNs > seekStartedNs) (seekReadyNs - seekStartedNs) / 1_000_000 else -1)
|
|
683
|
+
val snapshot = pairing
|
|
684
|
+
if (p == null || snapshot == null) return json.toString()
|
|
685
|
+
return json
|
|
686
|
+
/*
|
|
687
|
+
Both readings, taken here, in one call.
|
|
688
|
+
|
|
689
|
+
The position is the composition's own clock — driven by the audio
|
|
690
|
+
graph — and the presented frame is what the consumer actually
|
|
691
|
+
latched. Sampled together against one `System.nanoTime()`, the only
|
|
692
|
+
uncertainty left is the position's millisecond quantisation and the
|
|
693
|
+
frame's own age, and the age is reported so a sample taken during a
|
|
694
|
+
stall can be excluded rather than averaged in.
|
|
695
|
+
*/
|
|
696
|
+
.put("atNs", snapshot.atNs)
|
|
697
|
+
.put("positionUs", snapshot.positionUs)
|
|
698
|
+
.put("playing", snapshot.playing)
|
|
699
|
+
.put("playbackState", snapshot.playbackState)
|
|
700
|
+
.put("durationUs", snapshot.durationUs)
|
|
701
|
+
.put("presentedUs", if (snapshot.presentedUs < 0) JSONObject.NULL else snapshot.presentedUs)
|
|
702
|
+
.put("presentedAgeNs", snapshot.presentedAgeNs)
|
|
703
|
+
.put("readSpanNs", snapshot.readSpanNs)
|
|
704
|
+
// How old this pairing is by the time it is read. Reported so a
|
|
705
|
+
// verdict is never taken from a snapshot the pump failed to refresh.
|
|
706
|
+
.put("pairingAgeNs", System.nanoTime() - snapshot.atNs)
|
|
707
|
+
.toString()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Build the composition and attach it to the surface the preview already owns.
|
|
712
|
+
*
|
|
713
|
+
* @param specJson `{items:[{uri,startUs,endUs,speed}], mixUri, width, height}`
|
|
714
|
+
* exactly as `playlistOf()` produced it.
|
|
715
|
+
*/
|
|
716
|
+
fun start(context: Context, specJson: String): String {
|
|
717
|
+
return try {
|
|
718
|
+
val spec = JSONObject(specJson)
|
|
719
|
+
val itemsJson = spec.getJSONArray("items")
|
|
720
|
+
val width = spec.getInt("width")
|
|
721
|
+
val height = spec.getInt("height")
|
|
722
|
+
val mixUri = spec.optString("mixUri", "")
|
|
723
|
+
// Required, not optional. `CompositionPlayer` computes each
|
|
724
|
+
// sequence's duration up front and an audio-only item cannot supply
|
|
725
|
+
// one on its own, so a missing value fails with a bare
|
|
726
|
+
// `IllegalStateException` carrying only `C.TIME_UNSET`.
|
|
727
|
+
val mixDurationUs = spec.optLong("mixDurationUs", 0L)
|
|
728
|
+
/*
|
|
729
|
+
Refused before anything is built.
|
|
730
|
+
|
|
731
|
+
Zero means the caller did not claim, which is allowed and treated
|
|
732
|
+
as "no supersession in play" — the spike is also used for runs
|
|
733
|
+
that never race.
|
|
734
|
+
*/
|
|
735
|
+
val requestId = spec.optLong("requestId", 0L)
|
|
736
|
+
if (requestId != 0L && requestId < claimed.get()) {
|
|
737
|
+
superseded += 1
|
|
738
|
+
return JSONObject()
|
|
739
|
+
.put("ok", false).put("superseded", true)
|
|
740
|
+
.put("requestId", requestId).put("claimed", claimed.get())
|
|
741
|
+
.toString()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
val clips = ArrayList<EditedMediaItem>(itemsJson.length())
|
|
745
|
+
for (i in 0 until itemsJson.length()) {
|
|
746
|
+
clips.add(clipItem(itemsJson.getJSONObject(i)))
|
|
747
|
+
}
|
|
748
|
+
if (clips.isEmpty()) {
|
|
749
|
+
return JSONObject().put("ok", false).put("error", "no items").toString()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/*
|
|
753
|
+
The boundaries, from the same spec the composition is built from.
|
|
754
|
+
|
|
755
|
+
Each item occupies its trimmed source length divided by its speed,
|
|
756
|
+
and the seams are the running total. Taken from here rather than
|
|
757
|
+
asked of the player: the player reports where it *is*, and the
|
|
758
|
+
hold has to arm before it gets there.
|
|
759
|
+
|
|
760
|
+
The last boundary is the end of the composition and is not a seam;
|
|
761
|
+
nothing follows it to prepare.
|
|
762
|
+
*/
|
|
763
|
+
val seams = ArrayList<Long>(itemsJson.length())
|
|
764
|
+
var at = 0L
|
|
765
|
+
for (i in 0 until itemsJson.length()) {
|
|
766
|
+
val item = itemsJson.getJSONObject(i)
|
|
767
|
+
val speed = item.optDouble("speed", 1.0).takeIf { it > 0 } ?: 1.0
|
|
768
|
+
at += ((item.optLong("endUs") - item.optLong("startUs")) / speed).toLong()
|
|
769
|
+
if (i < itemsJson.length() - 1) seams.add(at)
|
|
770
|
+
}
|
|
771
|
+
seamHoldEnabled = spec.optBoolean("seamHold", false)
|
|
772
|
+
seamSeekEnabled = spec.optBoolean("seamSeek", false)
|
|
773
|
+
setSeamTargets(seams.toLongArray())
|
|
774
|
+
|
|
775
|
+
val sequences = ArrayList<EditedMediaItemSequence>(2)
|
|
776
|
+
sequences.add(EditedMediaItemSequence.Builder(clips).build())
|
|
777
|
+
if (mixUri.isNotEmpty()) {
|
|
778
|
+
sequences.add(
|
|
779
|
+
EditedMediaItemSequence.Builder(
|
|
780
|
+
EditedMediaItem.Builder(MediaItem.fromUri(mixUri))
|
|
781
|
+
// Audio only. The mix already covers the whole
|
|
782
|
+
// project; a video track here would be a second
|
|
783
|
+
// picture nobody asked for.
|
|
784
|
+
.setRemoveVideo(true)
|
|
785
|
+
.setDurationUs(mixDurationUs)
|
|
786
|
+
.build(),
|
|
787
|
+
).build(),
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
val composition = Composition.Builder(sequences).build()
|
|
791
|
+
|
|
792
|
+
val surface = VideoSurface.currentSurface()
|
|
793
|
+
?: return JSONObject().put("ok", false).put("error", "no surface").toString()
|
|
794
|
+
|
|
795
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
796
|
+
main.post {
|
|
797
|
+
runCatching {
|
|
798
|
+
releaseLocked()
|
|
799
|
+
lastError = null
|
|
800
|
+
startedAtNs = System.nanoTime()
|
|
801
|
+
readyAtNs = 0
|
|
802
|
+
val p = CompositionPlayer.Builder(context).build()
|
|
803
|
+
p.setVideoSurface(surface, Size(width, height))
|
|
804
|
+
// The same hook the two-player path uses, so the presented
|
|
805
|
+
// frame is measured by the machinery already trusted.
|
|
806
|
+
p.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
|
|
807
|
+
FrameClock.offer(presentationTimeUs, releaseTimeNs)
|
|
808
|
+
}
|
|
809
|
+
p.addListener(object : Player.Listener {
|
|
810
|
+
override fun onPlaybackStateChanged(state: Int) {
|
|
811
|
+
if (state == Player.STATE_READY) {
|
|
812
|
+
if (readyAtNs == 0L) readyAtNs = System.nanoTime()
|
|
813
|
+
if (seekStartedNs > seekReadyNs) seekReadyNs = System.nanoTime()
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
|
818
|
+
lastError = "${error.errorCodeName}: ${error.message}"
|
|
819
|
+
}
|
|
820
|
+
})
|
|
821
|
+
// Checked again inside the post: the claim may have moved
|
|
822
|
+
// while this request was queued behind another.
|
|
823
|
+
if (requestId != 0L && requestId < claimed.get()) {
|
|
824
|
+
superseded += 1
|
|
825
|
+
runCatching { p.release() }
|
|
826
|
+
lastError = null
|
|
827
|
+
done.countDown()
|
|
828
|
+
return@post
|
|
829
|
+
}
|
|
830
|
+
p.setComposition(composition)
|
|
831
|
+
p.prepare()
|
|
832
|
+
player = p
|
|
833
|
+
built += 1
|
|
834
|
+
pairing = null
|
|
835
|
+
epoch += 1
|
|
836
|
+
epochAtNs = System.nanoTime()
|
|
837
|
+
intentPlaying = false
|
|
838
|
+
releasePending = false
|
|
839
|
+
epochTargetUs = 0
|
|
840
|
+
main.removeCallbacks(pump)
|
|
841
|
+
pump.run()
|
|
842
|
+
}.onFailure {
|
|
843
|
+
// With the stack, because a spike's first failure is
|
|
844
|
+
// usually a contract it is holding wrongly and the class
|
|
845
|
+
// name alone names none of them.
|
|
846
|
+
lastError = it.javaClass.simpleName + ": " + it.message
|
|
847
|
+
android.util.Log.e("PictorSpike", "composition start failed", it)
|
|
848
|
+
}
|
|
849
|
+
done.countDown()
|
|
850
|
+
}
|
|
851
|
+
done.await(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
852
|
+
JSONObject().put("ok", lastError == null)
|
|
853
|
+
.put("error", lastError ?: JSONObject.NULL)
|
|
854
|
+
.put("sequences", sequences.size)
|
|
855
|
+
.put("clips", clips.size)
|
|
856
|
+
.toString()
|
|
857
|
+
} catch (e: Exception) {
|
|
858
|
+
JSONObject().put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
|
|
859
|
+
.toString()
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private fun clipItem(o: JSONObject): EditedMediaItem {
|
|
864
|
+
val startUs = o.getLong("startUs")
|
|
865
|
+
val endUs = o.getLong("endUs")
|
|
866
|
+
val speed = o.getDouble("speed").toFloat()
|
|
867
|
+
val media = MediaItem.Builder()
|
|
868
|
+
.setUri(o.getString("uri"))
|
|
869
|
+
.setClippingConfiguration(
|
|
870
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
871
|
+
.setStartPositionMs(startUs / 1000)
|
|
872
|
+
.setEndPositionMs(endUs / 1000)
|
|
873
|
+
.build(),
|
|
874
|
+
)
|
|
875
|
+
.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)
|
|
887
|
+
if (speed != 1f) {
|
|
888
|
+
/*
|
|
889
|
+
Speed as a source mapping, not as a clock rate.
|
|
890
|
+
|
|
891
|
+
`CompositionPlayer` accepts speed effects only from this factory,
|
|
892
|
+
and the reason it has to is the clock contract: a global
|
|
893
|
+
`PlaybackParameters` speed would scale the one axis every sequence
|
|
894
|
+
shares, dragging the mix to 2x with the picture. The pair is an
|
|
895
|
+
audio processor and a video timestamp adjustment, which is exactly
|
|
896
|
+
"the clip's compiled source-time mapping" expressed where the
|
|
897
|
+
player can honour it.
|
|
898
|
+
*/
|
|
899
|
+
val pair = Effects.createExperimentalSpeedChangingEffect(ConstantSpeed(speed))
|
|
900
|
+
builder.setEffects(Effects(listOf(pair.first), listOf(pair.second)))
|
|
901
|
+
}
|
|
902
|
+
return builder.build()
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The person asked for playback.
|
|
907
|
+
*
|
|
908
|
+
* Recorded as intent and armed as a hold; the transport starts from the
|
|
909
|
+
* pump once a current-epoch frame exists. Starting here would be the very
|
|
910
|
+
* thing this gate exists to stop.
|
|
911
|
+
*/
|
|
912
|
+
fun play(): String = onPlayer {
|
|
913
|
+
intentPlaying = true
|
|
914
|
+
// Resume targets where the transport already is.
|
|
915
|
+
armRelease(it.currentPosition * 1000)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
fun pause(): String = onPlayer {
|
|
919
|
+
// Intent first: a pause must invalidate a release that is about to
|
|
920
|
+
// happen, not race it.
|
|
921
|
+
intentPlaying = false
|
|
922
|
+
releasePending = false
|
|
923
|
+
cancelSeamHold()
|
|
924
|
+
FrameClock.setStreaming(false)
|
|
925
|
+
it.pause()
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
fun seek(positionUs: Long): String = onPlayer {
|
|
929
|
+
// A seek supersedes any hold: the boundary it was covering is no longer
|
|
930
|
+
// where playback is going.
|
|
931
|
+
cancelSeamHold()
|
|
932
|
+
seekStartedNs = System.nanoTime()
|
|
933
|
+
seekReadyNs = 0
|
|
934
|
+
// A new epoch: frames still in flight belong to the position being left.
|
|
935
|
+
epoch += 1
|
|
936
|
+
epochAtNs = seekStartedNs
|
|
937
|
+
// Frames in flight describe the position being left; their release
|
|
938
|
+
// times must not pace the new one.
|
|
939
|
+
FrameClock.bump()
|
|
940
|
+
/*
|
|
941
|
+
Held across the seek, and the older pending release is invalidated by
|
|
942
|
+
the re-arm. The player is paused so the seek does not play through
|
|
943
|
+
the interval where the picture is still the old position.
|
|
944
|
+
*/
|
|
945
|
+
it.pause()
|
|
946
|
+
armRelease(positionUs)
|
|
947
|
+
it.seekTo(positionUs / 1000)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
fun release(): String {
|
|
951
|
+
val done = java.util.concurrent.CountDownLatch(1)
|
|
952
|
+
main.post { releaseLocked(); done.countDown() }
|
|
953
|
+
done.await(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
954
|
+
return JSONObject().put("ok", true).put("released", releasedCount).toString()
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Main-thread only: `CompositionPlayer` requires one application thread. */
|
|
958
|
+
private fun releaseLocked() {
|
|
959
|
+
main.removeCallbacks(pump)
|
|
960
|
+
pairing = null
|
|
961
|
+
// Unmount and background both come through here: a pending release must
|
|
962
|
+
// not survive the player it was about to start.
|
|
963
|
+
intentPlaying = false
|
|
964
|
+
releasePending = false
|
|
965
|
+
FrameClock.setStreaming(false)
|
|
966
|
+
player?.let {
|
|
967
|
+
runCatching { it.release() }
|
|
968
|
+
releasedCount += 1
|
|
969
|
+
}
|
|
970
|
+
player = null
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private fun onPlayer(block: (CompositionPlayer) -> Unit): String {
|
|
974
|
+
val p = player ?: return JSONObject().put("ok", false).put("error", "not started").toString()
|
|
975
|
+
main.post { runCatching { block(p) }.onFailure { lastError = it.message } }
|
|
976
|
+
return JSONObject().put("ok", true).toString()
|
|
977
|
+
}
|
|
978
|
+
}
|