@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,26 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
/** Autolinked entry point. Android only; iOS is deferred by design. */
|
|
10
|
+
class PictorVideoPackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
12
|
+
if (name == PictorVideoModule.NAME) PictorVideoModule(reactContext) else null
|
|
13
|
+
|
|
14
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
15
|
+
mapOf(
|
|
16
|
+
PictorVideoModule.NAME to ReactModuleInfo(
|
|
17
|
+
PictorVideoModule.NAME,
|
|
18
|
+
PictorVideoModule.NAME,
|
|
19
|
+
false, // canOverrideExistingModule
|
|
20
|
+
false, // needsEagerInit
|
|
21
|
+
false, // isCxxModule
|
|
22
|
+
true, // isTurboModule
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The player's position, sampled on its own thread and extrapolated elsewhere.
|
|
5
|
+
*
|
|
6
|
+
* `ExoPlayer` may only be read from the application thread, and measuring
|
|
7
|
+
* synchronisation with a `post` and a latch puts a thread hop *inside the
|
|
8
|
+
* measurement*: the position arrives from later than the frame it is compared
|
|
9
|
+
* against. So the position is published as an anchor — `(positionUs,
|
|
10
|
+
* monotonicNs, rate)` — and readers extrapolate.
|
|
11
|
+
*
|
|
12
|
+
* **The anchor is only as good as its granularity.** `Player.currentPosition`
|
|
13
|
+
* does not advance continuously; it is itself derived, and between its updates
|
|
14
|
+
* it can return a value already behind real time. Extrapolating forward from a
|
|
15
|
+
* stale value yields a position that is too low, which shows up as a constant
|
|
16
|
+
* positive bias in any offset computed against it. [granularity] measures that
|
|
17
|
+
* directly rather than leaving it as a suspicion — how often the value changes,
|
|
18
|
+
* how big its steps are, and how old the anchor was when it was used.
|
|
19
|
+
*
|
|
20
|
+
* Lock-free: one immutable [Sample] behind a volatile reference. Readers on the
|
|
21
|
+
* render path never block and never see a half-written value.
|
|
22
|
+
*/
|
|
23
|
+
internal object PlaybackAnchor {
|
|
24
|
+
|
|
25
|
+
/** `System.nanoTime`, or a test's clock. Read through [nowNs]. */
|
|
26
|
+
@Volatile
|
|
27
|
+
var clock: () -> Long = { System.nanoTime() }
|
|
28
|
+
|
|
29
|
+
fun nowNs(): Long = clock()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An instant of player state. Immutable; replaced, never mutated.
|
|
33
|
+
*
|
|
34
|
+
* @param positionUs media position at [monotonicNs]
|
|
35
|
+
* @param monotonicNs when the position was read, on the same clock as
|
|
36
|
+
* `VideoFrameMetadataListener`'s `releaseTimeNs`
|
|
37
|
+
* @param rate playback speed, so extrapolation tracks 0.5× and 2×
|
|
38
|
+
* @param playing whether the clock is advancing at all
|
|
39
|
+
* @param buffering a stall, during which extrapolation is meaningless
|
|
40
|
+
* @param discontinuity a seek or source change has invalidated what came
|
|
41
|
+
* before
|
|
42
|
+
*/
|
|
43
|
+
data class Sample(
|
|
44
|
+
val positionUs: Long,
|
|
45
|
+
val monotonicNs: Long,
|
|
46
|
+
val rate: Float,
|
|
47
|
+
val playing: Boolean,
|
|
48
|
+
val buffering: Boolean,
|
|
49
|
+
val discontinuity: Boolean,
|
|
50
|
+
val generation: Int,
|
|
51
|
+
/**
|
|
52
|
+
* The playlist window [positionUs] is measured within.
|
|
53
|
+
*
|
|
54
|
+
* Carried because the position is window-local: a reader converting to
|
|
55
|
+
* project time needs to know which window it belongs to, and reading
|
|
56
|
+
* that separately would pair a position with an index from a different
|
|
57
|
+
* instant.
|
|
58
|
+
*/
|
|
59
|
+
val windowIndex: Int = 0,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
/** What the anchor's own resolution is, measured rather than assumed. */
|
|
63
|
+
data class Granularity(
|
|
64
|
+
/** Anchor publications so far. */
|
|
65
|
+
val publications: Long,
|
|
66
|
+
/** Publications where the position actually moved. */
|
|
67
|
+
val changes: Long,
|
|
68
|
+
/** Largest observed jump between consecutive distinct positions, µs. */
|
|
69
|
+
val maxStepUs: Long,
|
|
70
|
+
/** Sum of steps, for a mean. */
|
|
71
|
+
val totalStepUs: Long,
|
|
72
|
+
/** Longest the position stayed unchanged, in nanoseconds. */
|
|
73
|
+
val maxStaleNs: Long,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
private val EMPTY = Sample(0, 0, 1f, false, false, false, 0, 0)
|
|
77
|
+
|
|
78
|
+
@Volatile
|
|
79
|
+
private var current = EMPTY
|
|
80
|
+
|
|
81
|
+
@Volatile
|
|
82
|
+
private var publications = 0L
|
|
83
|
+
|
|
84
|
+
@Volatile
|
|
85
|
+
private var changes = 0L
|
|
86
|
+
|
|
87
|
+
@Volatile
|
|
88
|
+
private var maxStepUs = 0L
|
|
89
|
+
|
|
90
|
+
@Volatile
|
|
91
|
+
private var totalStepUs = 0L
|
|
92
|
+
|
|
93
|
+
@Volatile
|
|
94
|
+
private var maxStaleNs = 0L
|
|
95
|
+
|
|
96
|
+
@Volatile
|
|
97
|
+
private var lastChangeNs = 0L
|
|
98
|
+
|
|
99
|
+
@JvmStatic
|
|
100
|
+
fun publish(sample: Sample) {
|
|
101
|
+
val previous = current
|
|
102
|
+
publications += 1
|
|
103
|
+
if (sample.positionUs != previous.positionUs) {
|
|
104
|
+
changes += 1
|
|
105
|
+
val step = kotlin.math.abs(sample.positionUs - previous.positionUs)
|
|
106
|
+
if (previous.monotonicNs != 0L) {
|
|
107
|
+
totalStepUs += step
|
|
108
|
+
if (step > maxStepUs) maxStepUs = step
|
|
109
|
+
val stale = sample.monotonicNs - lastChangeNs
|
|
110
|
+
if (lastChangeNs != 0L && stale > maxStaleNs) maxStaleNs = stale
|
|
111
|
+
}
|
|
112
|
+
lastChangeNs = sample.monotonicNs
|
|
113
|
+
}
|
|
114
|
+
current = sample
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@JvmStatic
|
|
118
|
+
fun latest(): Sample = current
|
|
119
|
+
|
|
120
|
+
@JvmStatic
|
|
121
|
+
fun granularity(): Granularity =
|
|
122
|
+
Granularity(publications, changes, maxStepUs, totalStepUs, maxStaleNs)
|
|
123
|
+
|
|
124
|
+
/** How old the anchor was at [atNs], in nanoseconds. */
|
|
125
|
+
@JvmStatic
|
|
126
|
+
fun ageNs(atNs: Long): Long {
|
|
127
|
+
val anchor = current
|
|
128
|
+
if (anchor.monotonicNs == 0L) return 0
|
|
129
|
+
return atNs - anchor.monotonicNs
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where the playhead is at [atMonotonicNs], from the newest anchor.
|
|
134
|
+
*
|
|
135
|
+
* Returns the anchor unextrapolated when paused or buffering: the clock is
|
|
136
|
+
* not advancing then, and pretending otherwise would invent a position.
|
|
137
|
+
* Extrapolating backwards is allowed — a frame's release time can precede
|
|
138
|
+
* the newest anchor — because refusing would bias the result toward late.
|
|
139
|
+
*/
|
|
140
|
+
@JvmStatic
|
|
141
|
+
fun positionAt(atMonotonicNs: Long): Long {
|
|
142
|
+
val anchor = current
|
|
143
|
+
if (!anchor.playing || anchor.buffering || anchor.monotonicNs == 0L) {
|
|
144
|
+
return anchor.positionUs
|
|
145
|
+
}
|
|
146
|
+
val elapsedNs = atMonotonicNs - anchor.monotonicNs
|
|
147
|
+
return anchor.positionUs + (elapsedNs / 1000.0 * anchor.rate).toLong()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@JvmStatic
|
|
151
|
+
fun reset() {
|
|
152
|
+
current = EMPTY
|
|
153
|
+
publications = 0
|
|
154
|
+
changes = 0
|
|
155
|
+
maxStepUs = 0
|
|
156
|
+
totalStepUs = 0
|
|
157
|
+
maxStaleNs = 0
|
|
158
|
+
lastChangeNs = 0
|
|
159
|
+
clock = { System.nanoTime() }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import androidx.media3.common.MediaItem
|
|
8
|
+
import androidx.media3.common.PlaybackException
|
|
9
|
+
import androidx.media3.common.Player
|
|
10
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Plays one prepared mix, and nothing else.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a second player rather than a second track on the first
|
|
17
|
+
*
|
|
18
|
+
* The video player renders into a persistent external-OES surface and carries a
|
|
19
|
+
* playlist of clips, each with its own speed. The mix is a single continuous
|
|
20
|
+
* file covering the whole timeline, already re-timed — its position *is* the
|
|
21
|
+
* global timeline position, with no window index and no per-item rate. Feeding
|
|
22
|
+
* it to the video player would mean a playlist whose items disagree about what
|
|
23
|
+
* their positions mean.
|
|
24
|
+
*
|
|
25
|
+
* So this is deliberately small: one item, no surface, no playlist.
|
|
26
|
+
*
|
|
27
|
+
* ## Timeline position, not player position
|
|
28
|
+
*
|
|
29
|
+
* A caller asks for a global timeline microsecond and gets one back. That is
|
|
30
|
+
* the only coordinate the two players share, and it is what makes drift
|
|
31
|
+
* measurable — [state] reports the mix's own position alongside the wall clock
|
|
32
|
+
* it was sampled at, so a monitor compares two timestamps rather than
|
|
33
|
+
* accumulating deltas that drift by construction.
|
|
34
|
+
*
|
|
35
|
+
* ## Nothing here interprets the document
|
|
36
|
+
*
|
|
37
|
+
* No gain, no fades, no speed. All of that was compiled into the file by
|
|
38
|
+
* [AudioMixer] from the same program an export uses. This player's whole job is
|
|
39
|
+
* to be at the right position.
|
|
40
|
+
*/
|
|
41
|
+
internal class PreviewAudioPlayer private constructor(private val player: ExoPlayer) {
|
|
42
|
+
|
|
43
|
+
@Volatile private var released = false
|
|
44
|
+
|
|
45
|
+
/** Everything ExoPlayer is touched from is its own application thread. */
|
|
46
|
+
private val main = Handler(Looper.getMainLooper())
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The mix's position, published rather than fetched.
|
|
50
|
+
*
|
|
51
|
+
* `ExoPlayer` may only be read from the application thread, so a caller
|
|
52
|
+
* asking from anywhere else pays a thread hop *inside the measurement* —
|
|
53
|
+
* the position arrives from later than the moment it is compared against.
|
|
54
|
+
* The video player already solves this by publishing an anchor; the mix
|
|
55
|
+
* publishes one on the same clock so the two can simply be subtracted.
|
|
56
|
+
*/
|
|
57
|
+
@Volatile var anchor: Anchor = Anchor(0, 0, false, false, 0, 1f)
|
|
58
|
+
private set
|
|
59
|
+
|
|
60
|
+
data class Anchor(
|
|
61
|
+
val positionUs: Long,
|
|
62
|
+
val monotonicNs: Long,
|
|
63
|
+
val playing: Boolean,
|
|
64
|
+
val ready: Boolean,
|
|
65
|
+
val epoch: Long,
|
|
66
|
+
/**
|
|
67
|
+
* The speed this player is actually running at.
|
|
68
|
+
*
|
|
69
|
+
* Published rather than assumed. The mix covers global project time
|
|
70
|
+
* with every clip's speed already compiled in, so it must run at the
|
|
71
|
+
* preview rate alone — never at the current clip's 2x or 0.5x. An
|
|
72
|
+
* anchor extrapolated at 1.0 while the player runs at anything else
|
|
73
|
+
* reports an offset that is partly its own arithmetic, which is the
|
|
74
|
+
* one kind of error a synchronisation gate cannot see.
|
|
75
|
+
*/
|
|
76
|
+
val rate: Float,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
/** One re-posted Runnable, never a fresh lambda. */
|
|
80
|
+
private val anchorPump = object : Runnable {
|
|
81
|
+
override fun run() {
|
|
82
|
+
if (released) return
|
|
83
|
+
anchor = Anchor(
|
|
84
|
+
positionUs = player.currentPosition * 1000,
|
|
85
|
+
monotonicNs = System.nanoTime(),
|
|
86
|
+
playing = player.isPlaying,
|
|
87
|
+
ready = player.playbackState == Player.STATE_READY,
|
|
88
|
+
epoch = epoch,
|
|
89
|
+
rate = player.playbackParameters.speed,
|
|
90
|
+
)
|
|
91
|
+
// The same cadence the video player's anchor uses. 25 ms was
|
|
92
|
+
// chosen for resolution and is being tested as a suspect for the
|
|
93
|
+
// renderer stall: two players posting to one looper is twice the
|
|
94
|
+
// main-thread traffic the editor had before music existed.
|
|
95
|
+
// Off in the bisect configuration that excludes it. The pump is
|
|
96
|
+
// one of four things music adds to the main looper.
|
|
97
|
+
if (PreviewConfig.positionPump) main.postDelayed(this, 50)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Volatile private var lastError: String? = null
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The epoch this player belongs to.
|
|
105
|
+
*
|
|
106
|
+
* A caller stamps every start and seek. A callback or a state read carrying
|
|
107
|
+
* an older epoch is from a mix that has since been replaced, and acting on
|
|
108
|
+
* it would move the picture to match audio that is no longer playing.
|
|
109
|
+
*/
|
|
110
|
+
@Volatile var epoch: Long = 0
|
|
111
|
+
private set
|
|
112
|
+
|
|
113
|
+
private fun onPlayer(block: ExoPlayer.() -> Unit) {
|
|
114
|
+
if (released) return
|
|
115
|
+
if (Looper.myLooper() == Looper.getMainLooper()) player.block()
|
|
116
|
+
else main.post { if (!released) player.block() }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fun load(uriString: String, epoch: Long) {
|
|
120
|
+
this.epoch = epoch
|
|
121
|
+
lastError = null
|
|
122
|
+
PreviewTrace.record("audio.load") { JSONObject().put("epoch", epoch) }
|
|
123
|
+
// A new file has its own seek cost; carrying the old estimate forward
|
|
124
|
+
// would aim the first correction at the wrong place.
|
|
125
|
+
PreviewTrace.resetDrift()
|
|
126
|
+
onPlayer {
|
|
127
|
+
setMediaItem(MediaItem.fromUri(Uri.parse(uriString)))
|
|
128
|
+
prepare()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fun play(epoch: Long) {
|
|
133
|
+
if (!PreviewConfig.startMix) return
|
|
134
|
+
// A start for an epoch that has already been superseded is dropped
|
|
135
|
+
// rather than played: the alternative is audio from the previous edit.
|
|
136
|
+
PreviewTrace.record("audio.play") {
|
|
137
|
+
JSONObject().put("epoch", epoch).put("current", this.epoch)
|
|
138
|
+
.put("accepted", epoch == this.epoch)
|
|
139
|
+
}
|
|
140
|
+
if (epoch != this.epoch) return
|
|
141
|
+
onPlayer { play() }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fun pause() {
|
|
145
|
+
PreviewTrace.mark("audio.pause")
|
|
146
|
+
onPlayer { pause() }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun seekTo(positionUs: Long, epoch: Long) {
|
|
150
|
+
PreviewTrace.record("audio.seek") {
|
|
151
|
+
JSONObject().put("toUs", positionUs).put("epoch", epoch)
|
|
152
|
+
.put("current", this.epoch).put("accepted", epoch == this.epoch)
|
|
153
|
+
}
|
|
154
|
+
if (epoch != this.epoch) return
|
|
155
|
+
onPlayer { seekTo(positionUs / 1000) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Playback rate.
|
|
160
|
+
*
|
|
161
|
+
* Normally 1: clip speed is already compiled into the mix, so re-applying
|
|
162
|
+
* it here would apply it twice. It exists for the preview-rate control,
|
|
163
|
+
* which is a property of watching rather than of the document.
|
|
164
|
+
*/
|
|
165
|
+
fun setRate(rate: Float) {
|
|
166
|
+
onPlayer { setPlaybackSpeed(if (rate > 0f) rate else 1f) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Where the mix is, and when that was true.
|
|
171
|
+
*
|
|
172
|
+
* `monotonicNs` is sampled beside the position on purpose. A drift monitor
|
|
173
|
+
* that compares two positions read at unknown times is measuring its own
|
|
174
|
+
* scheduling jitter as much as anything else.
|
|
175
|
+
*/
|
|
176
|
+
fun state(): String {
|
|
177
|
+
val out = JSONObject().put("epoch", epoch).put("released", released)
|
|
178
|
+
if (released) return out.put("ok", false).toString()
|
|
179
|
+
val read = {
|
|
180
|
+
out.put("ok", true)
|
|
181
|
+
.put("positionUs", player.currentPosition * 1000)
|
|
182
|
+
.put("monotonicNs", System.nanoTime())
|
|
183
|
+
.put("playing", player.isPlaying)
|
|
184
|
+
.put("ready", player.playbackState == Player.STATE_READY)
|
|
185
|
+
.put("ended", player.playbackState == Player.STATE_ENDED)
|
|
186
|
+
.put("bufferedUs", player.bufferedPosition * 1000)
|
|
187
|
+
lastError?.let { out.put("error", it) }
|
|
188
|
+
Unit
|
|
189
|
+
}
|
|
190
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
191
|
+
read()
|
|
192
|
+
} else {
|
|
193
|
+
// A state read from the JS thread has to hop and wait: reading
|
|
194
|
+
// ExoPlayer off its own thread is undefined, and a stale cached
|
|
195
|
+
// value is exactly what a drift monitor must not be given.
|
|
196
|
+
val latch = java.util.concurrent.CountDownLatch(1)
|
|
197
|
+
main.post {
|
|
198
|
+
if (!released) runCatching { read() }
|
|
199
|
+
latch.countDown()
|
|
200
|
+
}
|
|
201
|
+
if (!latch.await(50, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
|
202
|
+
out.put("ok", false).put("error", "timed out reading player state")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out.toString()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fun release() {
|
|
209
|
+
if (released) return
|
|
210
|
+
PreviewTrace.mark("audio.release")
|
|
211
|
+
released = true
|
|
212
|
+
// On the player's own thread, explicitly. Nothing native is reachable
|
|
213
|
+
// from a finalizer here, for the reason the video binding documents.
|
|
214
|
+
if (Looper.myLooper() == Looper.getMainLooper()) player.release()
|
|
215
|
+
else main.post { player.release() }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
companion object {
|
|
219
|
+
@Volatile private var instance: PreviewAudioPlayer? = null
|
|
220
|
+
|
|
221
|
+
/** The one preview-audio player, created on demand. */
|
|
222
|
+
/**
|
|
223
|
+
* Players ever built, players released, and how many are alive now.
|
|
224
|
+
*
|
|
225
|
+
* A rebuild replaces the mix, and the question a bounds gate asks is
|
|
226
|
+
* whether it also replaces the *player*. One alive at all times is the
|
|
227
|
+
* design; the counters are what turn that from an argument about a
|
|
228
|
+
* singleton into a measurement.
|
|
229
|
+
*/
|
|
230
|
+
@Volatile var created = 0L
|
|
231
|
+
private set
|
|
232
|
+
|
|
233
|
+
@Volatile var releasedCount = 0L
|
|
234
|
+
private set
|
|
235
|
+
|
|
236
|
+
fun liveCount(): Int = if (instance?.released == false) 1 else 0
|
|
237
|
+
|
|
238
|
+
fun instanceCounters(): org.json.JSONObject =
|
|
239
|
+
org.json.JSONObject()
|
|
240
|
+
.put("created", created)
|
|
241
|
+
.put("released", releasedCount)
|
|
242
|
+
.put("live", liveCount())
|
|
243
|
+
|
|
244
|
+
fun require(context: Context): PreviewAudioPlayer {
|
|
245
|
+
instance?.let { if (!it.released) return it }
|
|
246
|
+
val built = build(context)
|
|
247
|
+
created += 1
|
|
248
|
+
instance = built
|
|
249
|
+
return built
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fun current(): PreviewAudioPlayer? = instance?.takeIf { !it.released }
|
|
253
|
+
|
|
254
|
+
fun releaseAll() {
|
|
255
|
+
if (instance?.released == false) releasedCount += 1
|
|
256
|
+
instance?.release()
|
|
257
|
+
instance = null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private fun build(context: Context): PreviewAudioPlayer {
|
|
261
|
+
val latch = java.util.concurrent.CountDownLatch(1)
|
|
262
|
+
var built: PreviewAudioPlayer? = null
|
|
263
|
+
val make = {
|
|
264
|
+
val exo = ExoPlayer.Builder(context).build()
|
|
265
|
+
val wrapper = PreviewAudioPlayer(exo)
|
|
266
|
+
exo.addListener(
|
|
267
|
+
object : Player.Listener {
|
|
268
|
+
override fun onPlayerError(error: PlaybackException) {
|
|
269
|
+
// Recorded rather than thrown: a mix that will not
|
|
270
|
+
// open is a state the coordinator has to see, not an
|
|
271
|
+
// exception on somebody else's thread.
|
|
272
|
+
wrapper.lastError = error.errorCodeName + ": " + error.message
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
built = wrapper
|
|
277
|
+
// Publishing starts with the player and stops with it: an
|
|
278
|
+
// anchor from a released player is a position that no longer
|
|
279
|
+
// exists.
|
|
280
|
+
wrapper.main.post(wrapper.anchorPump)
|
|
281
|
+
latch.countDown()
|
|
282
|
+
}
|
|
283
|
+
if (Looper.myLooper() == Looper.getMainLooper()) make()
|
|
284
|
+
else {
|
|
285
|
+
Handler(Looper.getMainLooper()).post(make)
|
|
286
|
+
latch.await(2, java.util.concurrent.TimeUnit.SECONDS)
|
|
287
|
+
}
|
|
288
|
+
return built ?: throw IllegalStateException("preview audio player could not be created")
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which parts of the music preview exist, for bisecting a failure.
|
|
7
|
+
*
|
|
8
|
+
* The picture stops advancing when music is active and the counters say the
|
|
9
|
+
* *consumer* stopped: `available` reaches three and stays there while the
|
|
10
|
+
* decoder keeps offering. Three availability callbacks and a stalled consumer
|
|
11
|
+
* is what a full BufferQueue looks like — nothing latches, so nothing is
|
|
12
|
+
* recycled, so the producer has nowhere to put frame four.
|
|
13
|
+
*
|
|
14
|
+
* That narrows it to the render path, and the music preview added four things
|
|
15
|
+
* that could reach it: a second `ExoPlayer`, its position pump on the main
|
|
16
|
+
* looper, the stall coordinator that ticks from the video player's own pump,
|
|
17
|
+
* and the mix actually playing. Turning them off one at a time is the only way
|
|
18
|
+
* to say which, and guessing between them is how the last three "fixes" in this
|
|
19
|
+
* area were tuning rather than repair.
|
|
20
|
+
*
|
|
21
|
+
* Every flag defaults to on, so a build without diagnostics behaves exactly as
|
|
22
|
+
* production does. They are settable only through the diagnostics-gated module
|
|
23
|
+
* method, and the harness persists a choice so a *fresh process* can apply it —
|
|
24
|
+
* an intermittent failure re-measured inside a warm process is not the same
|
|
25
|
+
* experiment.
|
|
26
|
+
*/
|
|
27
|
+
internal object PreviewConfig {
|
|
28
|
+
|
|
29
|
+
/** Create and load the second player at all. */
|
|
30
|
+
@Volatile var loadPlayer = true
|
|
31
|
+
|
|
32
|
+
/** Let the mix actually start when playback starts. */
|
|
33
|
+
@Volatile var startMix = true
|
|
34
|
+
|
|
35
|
+
/** Publish the mix's position anchor from the main looper. */
|
|
36
|
+
@Volatile var positionPump = true
|
|
37
|
+
|
|
38
|
+
/** Run the stall coordinator. */
|
|
39
|
+
@Volatile var coordinator = true
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Let the drift check act on what it finds.
|
|
43
|
+
*
|
|
44
|
+
* The controlled comparison for correction-seek interference. The trace
|
|
45
|
+
* implicates it directly: at a seam the project clock runs at half rate for
|
|
46
|
+
* ~450 ms while the player applies a speed change it has already reported,
|
|
47
|
+
* a correction fires on that wrong reading, and the overshoot it leaves is
|
|
48
|
+
* larger and longer-lived than the excursion it was answering. Turning the
|
|
49
|
+
* correction off — while still measuring and recording what it *would* have
|
|
50
|
+
* done — separates the excursion from the response to it.
|
|
51
|
+
*/
|
|
52
|
+
@Volatile var driftCorrection = true
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Keep a copy of the mixed PCM for the attribution gate.
|
|
56
|
+
*
|
|
57
|
+
* Off by default. A 33-second stereo mix is 12.7 MB and this held one for
|
|
58
|
+
* the life of the process, in addition to the array being encoded — a cost
|
|
59
|
+
* paid by every run after the one experiment that wanted it.
|
|
60
|
+
*/
|
|
61
|
+
@Volatile var retainMixedPcm = false
|
|
62
|
+
|
|
63
|
+
fun apply(json: String): JSONObject {
|
|
64
|
+
if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
|
|
65
|
+
return try {
|
|
66
|
+
val o = JSONObject(json)
|
|
67
|
+
if (o.has("loadPlayer")) loadPlayer = o.getBoolean("loadPlayer")
|
|
68
|
+
if (o.has("startMix")) startMix = o.getBoolean("startMix")
|
|
69
|
+
if (o.has("positionPump")) positionPump = o.getBoolean("positionPump")
|
|
70
|
+
if (o.has("coordinator")) coordinator = o.getBoolean("coordinator")
|
|
71
|
+
if (o.has("driftCorrection")) driftCorrection = o.getBoolean("driftCorrection")
|
|
72
|
+
if (o.has("retainMixedPcm")) retainMixedPcm = o.getBoolean("retainMixedPcm")
|
|
73
|
+
current().put("ok", true)
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
JSONObject().put("ok", false).put("error", e.javaClass.simpleName)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fun current(): JSONObject =
|
|
80
|
+
JSONObject()
|
|
81
|
+
.put("loadPlayer", loadPlayer)
|
|
82
|
+
.put("startMix", startMix)
|
|
83
|
+
.put("positionPump", positionPump)
|
|
84
|
+
.put("coordinator", coordinator)
|
|
85
|
+
.put("driftCorrection", driftCorrection)
|
|
86
|
+
.put("retainMixedPcm", retainMixedPcm)
|
|
87
|
+
|
|
88
|
+
fun reset() {
|
|
89
|
+
loadPlayer = true
|
|
90
|
+
startMix = true
|
|
91
|
+
positionPump = true
|
|
92
|
+
coordinator = true
|
|
93
|
+
driftCorrection = true
|
|
94
|
+
retainMixedPcm = false
|
|
95
|
+
}
|
|
96
|
+
}
|