@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,162 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.MediaExtractor
|
|
5
|
+
import android.media.MediaFormat
|
|
6
|
+
import android.media.MediaMetadataRetriever
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a file actually contains, asked before it is allowed into a project.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the host probes and the mixer does not
|
|
14
|
+
*
|
|
15
|
+
* `compileAudioMixProgram` runs in plain Node with no decoder, so it can only
|
|
16
|
+
* believe what the document tells it: `metadata.hasAudio === false` means
|
|
17
|
+
* silence and anything else means audio. A project that claimed audio a file
|
|
18
|
+
* did not have already cost one whole export, which failed at the encoding
|
|
19
|
+
* stage with nothing on screen to explain it — the encoder had been asked to
|
|
20
|
+
* decode a track that was not there.
|
|
21
|
+
*
|
|
22
|
+
* So this is the only thing that gets to answer the question, and it answers it
|
|
23
|
+
* by opening the file rather than by reading its name. A `.m4a` carrying only
|
|
24
|
+
* cover art is a real thing, and it is indistinguishable from music until
|
|
25
|
+
* someone looks at the tracks.
|
|
26
|
+
*
|
|
27
|
+
* ## Everything is an outcome
|
|
28
|
+
*
|
|
29
|
+
* A probe of an unreadable file is a normal result, not an exception: the user
|
|
30
|
+
* picked something, and "it could not be read" is the answer. Every failure
|
|
31
|
+
* comes back as `ok: false` with a reason, and nothing here throws into JS.
|
|
32
|
+
*
|
|
33
|
+
* Nothing in the returned JSON identifies the file. The display name is the
|
|
34
|
+
* host's to carry — a `content://` URI embeds an id and sometimes a user's own
|
|
35
|
+
* folder names, and none of that belongs in a log.
|
|
36
|
+
*/
|
|
37
|
+
object AudioProbe {
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Open [uri], report its audio.
|
|
41
|
+
*
|
|
42
|
+
* Returns JSON: `{ok, durationUs, hasAudioTrack, seekable, mimeType}` or
|
|
43
|
+
* `{ok: false, reason}`.
|
|
44
|
+
*/
|
|
45
|
+
fun probe(context: Context, uri: String): JSONObject {
|
|
46
|
+
val out = JSONObject()
|
|
47
|
+
val parsed =
|
|
48
|
+
try {
|
|
49
|
+
Uri.parse(uri)
|
|
50
|
+
} catch (e: Exception) {
|
|
51
|
+
return out.put("ok", false).put("reason", "unparseable uri")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
val extractor = MediaExtractor()
|
|
55
|
+
try {
|
|
56
|
+
extractor.setDataSource(context, parsed, null)
|
|
57
|
+
} catch (e: Exception) {
|
|
58
|
+
// The common real case: a picker URI whose permission has lapsed,
|
|
59
|
+
// or a container no extractor on this device can open.
|
|
60
|
+
extractor.release()
|
|
61
|
+
return out.put("ok", false).put("reason", reasonOf(e))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
var audioTrack = -1
|
|
66
|
+
var mimeType = ""
|
|
67
|
+
for (i in 0 until extractor.trackCount) {
|
|
68
|
+
val format = extractor.getTrackFormat(i)
|
|
69
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
70
|
+
if (mime.startsWith("audio/")) {
|
|
71
|
+
audioTrack = i
|
|
72
|
+
mimeType = mime
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
val durationUs = durationOf(context, parsed, extractor, audioTrack)
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
Seekability, tested rather than assumed.
|
|
81
|
+
|
|
82
|
+
Trimming, fades and preview scrubbing are all seeks, so a source
|
|
83
|
+
that cannot seek would accept every edit Music offers and honour
|
|
84
|
+
none of them. There is no flag for this on `MediaExtractor`, so
|
|
85
|
+
the question is put to the file: seek to the middle and see
|
|
86
|
+
whether the next sample actually moved. A container that ignores
|
|
87
|
+
the request answers 0 and is refused.
|
|
88
|
+
*/
|
|
89
|
+
var seekable = false
|
|
90
|
+
if (audioTrack >= 0 && durationUs > 0) {
|
|
91
|
+
seekable =
|
|
92
|
+
try {
|
|
93
|
+
extractor.selectTrack(audioTrack)
|
|
94
|
+
val target = durationUs / 2
|
|
95
|
+
extractor.seekTo(target, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
96
|
+
val landed = extractor.sampleTime
|
|
97
|
+
// A quarter of the file is a wide margin on purpose: a
|
|
98
|
+
// sparse sync table can land well short of the target
|
|
99
|
+
// and still be perfectly seekable.
|
|
100
|
+
landed > 0 && landed > durationUs / 4
|
|
101
|
+
} catch (e: Exception) {
|
|
102
|
+
false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return out
|
|
107
|
+
.put("ok", true)
|
|
108
|
+
.put("durationUs", durationUs)
|
|
109
|
+
.put("hasAudioTrack", audioTrack >= 0)
|
|
110
|
+
.put("seekable", seekable)
|
|
111
|
+
.put("mimeType", mimeType)
|
|
112
|
+
} catch (e: Exception) {
|
|
113
|
+
return out.put("ok", false).put("reason", reasonOf(e))
|
|
114
|
+
} finally {
|
|
115
|
+
extractor.release()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The track's own duration, falling back to the container's.
|
|
121
|
+
*
|
|
122
|
+
* A track format usually carries `KEY_DURATION`, and when it does it is the
|
|
123
|
+
* more precise answer. Some containers leave it off the track and put it
|
|
124
|
+
* only in the metadata, so both are asked before the file is called
|
|
125
|
+
* unreadable.
|
|
126
|
+
*/
|
|
127
|
+
private fun durationOf(
|
|
128
|
+
context: Context,
|
|
129
|
+
uri: Uri,
|
|
130
|
+
extractor: MediaExtractor,
|
|
131
|
+
audioTrack: Int,
|
|
132
|
+
): Long {
|
|
133
|
+
if (audioTrack >= 0) {
|
|
134
|
+
val format = extractor.getTrackFormat(audioTrack)
|
|
135
|
+
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
|
136
|
+
val fromTrack = format.getLong(MediaFormat.KEY_DURATION)
|
|
137
|
+
if (fromTrack > 0) return fromTrack
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
val retriever = MediaMetadataRetriever()
|
|
141
|
+
return try {
|
|
142
|
+
retriever.setDataSource(context, uri)
|
|
143
|
+
val ms =
|
|
144
|
+
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
|
145
|
+
// Milliseconds there, microseconds everywhere in Pictor. The wrong
|
|
146
|
+
// one makes every song a thousand times too short.
|
|
147
|
+
if (ms != null && ms > 0) ms * 1000 else 0
|
|
148
|
+
} catch (e: Exception) {
|
|
149
|
+
0
|
|
150
|
+
} finally {
|
|
151
|
+
try {
|
|
152
|
+
retriever.release()
|
|
153
|
+
} catch (e: Exception) {
|
|
154
|
+
// Releasing a retriever that never opened is not a failure
|
|
155
|
+
// worth reporting over the one that brought us here.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun reasonOf(e: Exception): String =
|
|
161
|
+
e.javaClass.simpleName + (e.message?.let { ": $it" } ?: "")
|
|
162
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.opengl.GLES20
|
|
4
|
+
import androidx.media3.common.GlObjectsProvider
|
|
5
|
+
import androidx.media3.common.GlTextureInfo
|
|
6
|
+
import androidx.media3.common.VideoFrameProcessingException
|
|
7
|
+
import androidx.media3.common.util.GlProgram
|
|
8
|
+
import androidx.media3.common.util.GlUtil
|
|
9
|
+
import androidx.media3.common.util.UnstableApi
|
|
10
|
+
import androidx.media3.effect.GlEffect
|
|
11
|
+
import androidx.media3.effect.GlShaderProgram
|
|
12
|
+
import java.util.ArrayDeque
|
|
13
|
+
import java.util.concurrent.Executor
|
|
14
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Puts frames on the target grid, **upstream of the scene interpreter**.
|
|
18
|
+
*
|
|
19
|
+
* `setSpeed` re-times without resampling: the source frame count survives every
|
|
20
|
+
* rate and only the interval moves. `FrameDropEffect` closes the rates above 1×,
|
|
21
|
+
* where the fix is to remove frames. Below 1× the fix is to add them, and
|
|
22
|
+
* Media3 has no public mechanism that does — the encoder's repeat interval is
|
|
23
|
+
* wall-clock driven, never fires in a non-real-time transcode, and sits
|
|
24
|
+
* downstream of the GL pipeline where a repeated frame would never be drawn by
|
|
25
|
+
* the interpreter at all.
|
|
26
|
+
*
|
|
27
|
+
* So repetition happens here, before the interpreter, and every emitted frame
|
|
28
|
+
* carries its own output presentation timestamp into scene selection. A repeat
|
|
29
|
+
* is a real frame with a real time: overlays, transforms and opacity apply to it
|
|
30
|
+
* exactly as they do to any other.
|
|
31
|
+
*
|
|
32
|
+
* ## It computes nothing
|
|
33
|
+
*
|
|
34
|
+
* The emission plan is compiled by `resampleClip` in TypeScript and arrives as a
|
|
35
|
+
* count per input frame plus the timestamps to stamp them with. This class walks
|
|
36
|
+
* that list. It holds no rate, no duration and no grid arithmetic — the whole of
|
|
37
|
+
* its cleverness is the ownership protocol below.
|
|
38
|
+
*
|
|
39
|
+
* ## Ownership
|
|
40
|
+
*
|
|
41
|
+
* Media3 lends an input texture and takes it back when told. An output frame
|
|
42
|
+
* handed downstream may be read long after, so an input must not be released
|
|
43
|
+
* while any output still refers to it — but outputs here are **copies**, made
|
|
44
|
+
* while the input is still owned, which is what makes repetition safe at all.
|
|
45
|
+
*
|
|
46
|
+
* The input is released only after its last copy has been made. Copies are
|
|
47
|
+
* emitted one at a time, bounded by the texture pool, and the next is made when
|
|
48
|
+
* the downstream returns one. That bound is the backpressure: a slow consumer
|
|
49
|
+
* stalls this program instead of growing its buffer.
|
|
50
|
+
*/
|
|
51
|
+
@UnstableApi
|
|
52
|
+
internal class CadenceEffect(
|
|
53
|
+
/** Copies each input frame owes, in arrival order. Compiled in TypeScript. */
|
|
54
|
+
private val emitsPerInput: IntArray,
|
|
55
|
+
/** Output timestamps, flattened in emission order. Compiled in TypeScript. */
|
|
56
|
+
private val outputPtsUs: LongArray,
|
|
57
|
+
/** Outstanding output frames allowed at once. */
|
|
58
|
+
private val maxPending: Int = 2,
|
|
59
|
+
private val failAfterEmits: Int = -1,
|
|
60
|
+
/** Gate-only: go quiet after N frames — no emit, no beat, no error. */
|
|
61
|
+
private val stallAfterEmits: Int = -1,
|
|
62
|
+
) : GlEffect {
|
|
63
|
+
|
|
64
|
+
val emitted = AtomicLong(0)
|
|
65
|
+
val inputsSeen = AtomicLong(0)
|
|
66
|
+
val glCreated = AtomicLong(0)
|
|
67
|
+
val glDeleted = AtomicLong(0)
|
|
68
|
+
val peakPending = AtomicLong(0)
|
|
69
|
+
val stalls = AtomicLong(0)
|
|
70
|
+
private val problems = mutableListOf<String>()
|
|
71
|
+
|
|
72
|
+
fun trace(): String = synchronized(problems) {
|
|
73
|
+
"inputs=" + inputsSeen.get() + " emitted=" + emitted.get() +
|
|
74
|
+
" glCreated=" + glCreated.get() + " glDeleted=" + glDeleted.get() +
|
|
75
|
+
" peakPending=" + peakPending.get() + " stalls=" + stalls.get() +
|
|
76
|
+
(if (problems.isEmpty()) "" else " problems=" + problems.joinToString("|"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun problem(message: String) {
|
|
80
|
+
synchronized(problems) { if (problems.size < 8) problems.add(message) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override fun toGlShaderProgram(context: android.content.Context, useHdr: Boolean): GlShaderProgram =
|
|
84
|
+
Program()
|
|
85
|
+
|
|
86
|
+
private inner class Program : GlShaderProgram {
|
|
87
|
+
private var inputListener: GlShaderProgram.InputListener? = null
|
|
88
|
+
private var outputListener: GlShaderProgram.OutputListener? = null
|
|
89
|
+
private var errorExecutor: Executor? = null
|
|
90
|
+
private var errorListener: GlShaderProgram.ErrorListener? = null
|
|
91
|
+
|
|
92
|
+
private var glProgram: GlProgram? = null
|
|
93
|
+
private var width = 0
|
|
94
|
+
private var height = 0
|
|
95
|
+
|
|
96
|
+
/** Textures this program owns. Created once, reused, freed in [release]. */
|
|
97
|
+
private val all = mutableListOf<GlTextureInfo>()
|
|
98
|
+
private val free = ArrayDeque<GlTextureInfo>()
|
|
99
|
+
private var pending = 0
|
|
100
|
+
|
|
101
|
+
/** The borrowed input, held only while copies of it are still owed. */
|
|
102
|
+
private var heldInput: GlTextureInfo? = null
|
|
103
|
+
private var owed = 0
|
|
104
|
+
private var emitCursor = 0
|
|
105
|
+
private var inputOrdinal = 0
|
|
106
|
+
private var endOfStreamSignalled = false
|
|
107
|
+
private var released = false
|
|
108
|
+
/** Captured once: a beat is only evidence for the job that made this. */
|
|
109
|
+
private val beatEpoch = ExportHeartbeat.currentEpoch()
|
|
110
|
+
|
|
111
|
+
override fun setInputListener(listener: GlShaderProgram.InputListener) {
|
|
112
|
+
inputListener = listener
|
|
113
|
+
// Only invite a frame when there is somewhere to put its first copy.
|
|
114
|
+
if (heldInput == null && !released) listener.onReadyToAcceptInputFrame()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun setOutputListener(listener: GlShaderProgram.OutputListener) {
|
|
118
|
+
outputListener = listener
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override fun setErrorListener(executor: Executor, listener: GlShaderProgram.ErrorListener) {
|
|
122
|
+
errorExecutor = executor
|
|
123
|
+
errorListener = listener
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
override fun queueInputFrame(
|
|
127
|
+
provider: GlObjectsProvider,
|
|
128
|
+
input: GlTextureInfo,
|
|
129
|
+
presentationTimeUs: Long,
|
|
130
|
+
) {
|
|
131
|
+
if (released) return
|
|
132
|
+
inputsSeen.incrementAndGet()
|
|
133
|
+
try {
|
|
134
|
+
ensureGl(provider, input.width, input.height)
|
|
135
|
+
|
|
136
|
+
val ordinal = inputOrdinal
|
|
137
|
+
inputOrdinal += 1
|
|
138
|
+
owed = if (ordinal < emitsPerInput.size) emitsPerInput[ordinal] else 0
|
|
139
|
+
if (owed == 0) {
|
|
140
|
+
/*
|
|
141
|
+
A frame the plan does not use.
|
|
142
|
+
|
|
143
|
+
Released immediately and never copied — this is how the
|
|
144
|
+
program drops above 1× without a second mechanism. The
|
|
145
|
+
interpreter downstream never sees it, so scene executions
|
|
146
|
+
stay equal to output frames.
|
|
147
|
+
*/
|
|
148
|
+
inputListener?.onInputFrameProcessed(input)
|
|
149
|
+
inputListener?.onReadyToAcceptInputFrame()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
heldInput = input
|
|
153
|
+
pump()
|
|
154
|
+
} catch (e: Exception) {
|
|
155
|
+
fail(e)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Emit as many copies as the pool allows, then release the input if done.
|
|
161
|
+
*
|
|
162
|
+
* The input is released *after* the last copy, never before: a copy is
|
|
163
|
+
* made from the borrowed texture, so returning it early would leave a
|
|
164
|
+
* draw reading memory Media3 has taken back.
|
|
165
|
+
*/
|
|
166
|
+
private fun pump() {
|
|
167
|
+
val input = heldInput ?: return
|
|
168
|
+
while (owed > 0 && pending < maxPending) {
|
|
169
|
+
val target = free.poll() ?: break
|
|
170
|
+
val pts = if (emitCursor < outputPtsUs.size) outputPtsUs[emitCursor] else 0L
|
|
171
|
+
emitCursor += 1
|
|
172
|
+
|
|
173
|
+
if (stallAfterEmits >= 0 && emitted.get() >= stallAfterEmits) {
|
|
174
|
+
// Return holding the input: nothing is emitted, nothing
|
|
175
|
+
// beats, and no exception is raised. Silence is the point.
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (failAfterEmits >= 0 && emitted.get() >= failAfterEmits) {
|
|
179
|
+
throw VideoFrameProcessingException(
|
|
180
|
+
RuntimeException("injected cadence failure at emit " + emitted.get()),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
copy(input, target)
|
|
185
|
+
owed -= 1
|
|
186
|
+
pending += 1
|
|
187
|
+
if (pending.toLong() > peakPending.get()) peakPending.set(pending.toLong())
|
|
188
|
+
emitted.incrementAndGet()
|
|
189
|
+
// An output frame is the unit the bar is weighted in, so it is
|
|
190
|
+
// counted here rather than inferred from anything downstream.
|
|
191
|
+
ExportProgress.frame(beatEpoch)
|
|
192
|
+
// Liveness, not progress: one repeated copy moves the bar
|
|
193
|
+
// barely at all and still proves the pipeline is running.
|
|
194
|
+
ExportHeartbeat.beat("cadence", beatEpoch)
|
|
195
|
+
outputListener?.onOutputFrameAvailable(target, pts)
|
|
196
|
+
}
|
|
197
|
+
if (owed > 0) {
|
|
198
|
+
// Bounded, so a slow consumer stalls this program rather than
|
|
199
|
+
// growing an unbounded queue of copies.
|
|
200
|
+
stalls.incrementAndGet()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
heldInput = null
|
|
204
|
+
inputListener?.onInputFrameProcessed(input)
|
|
205
|
+
inputListener?.onReadyToAcceptInputFrame()
|
|
206
|
+
maybeSignalEnd()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private fun copy(input: GlTextureInfo, target: GlTextureInfo) {
|
|
210
|
+
val program = glProgram ?: throw IllegalStateException("no GL program")
|
|
211
|
+
GlUtil.focusFramebufferUsingCurrentContext(target.fboId, target.width, target.height)
|
|
212
|
+
GlUtil.clearFocusedBuffers()
|
|
213
|
+
program.use()
|
|
214
|
+
program.setSamplerTexIdUniform("uTexSampler", input.texId, 0)
|
|
215
|
+
program.bindAttributesAndUniforms()
|
|
216
|
+
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
|
217
|
+
GlUtil.checkGlError()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun ensureGl(provider: GlObjectsProvider, w: Int, h: Int) {
|
|
221
|
+
if (glProgram != null && w == width && h == height) return
|
|
222
|
+
releaseGl()
|
|
223
|
+
width = w
|
|
224
|
+
height = h
|
|
225
|
+
val program = GlProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
|
226
|
+
program.setBufferAttribute(
|
|
227
|
+
"aFramePosition",
|
|
228
|
+
floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f),
|
|
229
|
+
2,
|
|
230
|
+
)
|
|
231
|
+
glProgram = program
|
|
232
|
+
// The pool is the buffering bound, allocated once and reused. One
|
|
233
|
+
// spare beyond `maxPending` so a copy can be prepared while the
|
|
234
|
+
// downstream still holds the previous one.
|
|
235
|
+
for (i in 0 until maxPending + 1) {
|
|
236
|
+
val texId = GlUtil.createTexture(w, h, /* useHighPrecisionColorComponents= */ false)
|
|
237
|
+
val info = provider.createBuffersForTexture(texId, w, h)
|
|
238
|
+
all.add(info)
|
|
239
|
+
free.add(info)
|
|
240
|
+
}
|
|
241
|
+
glCreated.incrementAndGet()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override fun releaseOutputFrame(outputTexture: GlTextureInfo) {
|
|
245
|
+
if (released) return
|
|
246
|
+
pending -= 1
|
|
247
|
+
free.add(outputTexture)
|
|
248
|
+
try {
|
|
249
|
+
pump()
|
|
250
|
+
} catch (e: Exception) {
|
|
251
|
+
fail(e)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
override fun signalEndOfCurrentInputStream() {
|
|
256
|
+
endOfStreamSignalled = true
|
|
257
|
+
maybeSignalEnd()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* End only when nothing is owed.
|
|
262
|
+
*
|
|
263
|
+
* Signalling while copies are outstanding would truncate the clip;
|
|
264
|
+
* emitting anything after the plan is exhausted would extend it past its
|
|
265
|
+
* duration. The plan's length is the clip's length, so neither happens by
|
|
266
|
+
* construction — and the guard is here because "by construction" is worth
|
|
267
|
+
* one comparison.
|
|
268
|
+
*/
|
|
269
|
+
private fun maybeSignalEnd() {
|
|
270
|
+
if (endOfStreamSignalled && heldInput == null && owed == 0) {
|
|
271
|
+
outputListener?.onCurrentOutputStreamEnded()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
override fun flush() {
|
|
276
|
+
// Everything borrowed is dropped and the plan restarts. Media3 owns
|
|
277
|
+
// the input across a flush, so it is not released here.
|
|
278
|
+
heldInput = null
|
|
279
|
+
owed = 0
|
|
280
|
+
pending = 0
|
|
281
|
+
emitCursor = 0
|
|
282
|
+
inputOrdinal = 0
|
|
283
|
+
endOfStreamSignalled = false
|
|
284
|
+
free.clear()
|
|
285
|
+
free.addAll(all)
|
|
286
|
+
inputListener?.onFlush()
|
|
287
|
+
inputListener?.onReadyToAcceptInputFrame()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
override fun release() {
|
|
291
|
+
released = true
|
|
292
|
+
if (heldInput != null) problem("released while holding an input")
|
|
293
|
+
if (pending != 0) problem("released with " + pending + " outputs outstanding")
|
|
294
|
+
releaseGl()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun releaseGl() {
|
|
298
|
+
runCatching { glProgram?.delete() }
|
|
299
|
+
if (glProgram != null) glDeleted.incrementAndGet()
|
|
300
|
+
glProgram = null
|
|
301
|
+
for (info in all) runCatching { info.release() }
|
|
302
|
+
all.clear()
|
|
303
|
+
free.clear()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun fail(e: Exception) {
|
|
307
|
+
problem(e.javaClass.simpleName + ": " + e.message)
|
|
308
|
+
val listener = errorListener
|
|
309
|
+
val executor = errorExecutor
|
|
310
|
+
val wrapped =
|
|
311
|
+
if (e is VideoFrameProcessingException) e else VideoFrameProcessingException(e)
|
|
312
|
+
if (listener != null && executor != null) {
|
|
313
|
+
executor.execute { listener.onError(wrapped) }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private companion object {
|
|
319
|
+
const val VERTEX_SHADER = """
|
|
320
|
+
attribute vec4 aFramePosition;
|
|
321
|
+
varying vec2 vTexCoord;
|
|
322
|
+
void main() {
|
|
323
|
+
gl_Position = aFramePosition;
|
|
324
|
+
vTexCoord = aFramePosition.xy * 0.5 + 0.5;
|
|
325
|
+
}
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
const val FRAGMENT_SHADER = """
|
|
329
|
+
precision mediump float;
|
|
330
|
+
uniform sampler2D uTexSampler;
|
|
331
|
+
varying vec2 vTexCoord;
|
|
332
|
+
void main() {
|
|
333
|
+
gl_FragColor = texture2D(uTexSampler, vTexCoord);
|
|
334
|
+
}
|
|
335
|
+
"""
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every codec mix preparation owns, from creation to the end of release.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists
|
|
11
|
+
*
|
|
12
|
+
* The picture stops advancing after a mix is prepared, and the minimal failing
|
|
13
|
+
* configuration is preparation alone — no second player, no position pump, no
|
|
14
|
+
* coordinator. Preparation's distinguishing feature is codecs: four decoders
|
|
15
|
+
* for the sources, an encoder for the mix, and another encode/decode pair
|
|
16
|
+
* inside the priming calibration. If one of them outlives the job, the video
|
|
17
|
+
* decoder is competing for a hardware resource that nothing is going to give
|
|
18
|
+
* back.
|
|
19
|
+
*
|
|
20
|
+
* That is a hypothesis, and this is what turns it into an answer. A `release()`
|
|
21
|
+
* that was called is not the same as a release that finished, and a `finally`
|
|
22
|
+
* block that ran is not the same as one that ran without throwing — so both
|
|
23
|
+
* ends of every codec's life are recorded, with the exception path if there
|
|
24
|
+
* was one.
|
|
25
|
+
*
|
|
26
|
+
* ## What it deliberately does not cover
|
|
27
|
+
*
|
|
28
|
+
* The preview video decoder. That belongs to `Media3Player` and lives as long
|
|
29
|
+
* as the screen does; counting it here would make "live after publication"
|
|
30
|
+
* always true and the check worthless. Ownership is the point: these are the
|
|
31
|
+
* codecs a *job* created and must give back before it publishes.
|
|
32
|
+
*/
|
|
33
|
+
internal object CodecLedger {
|
|
34
|
+
|
|
35
|
+
private val nextId = AtomicLong(0)
|
|
36
|
+
|
|
37
|
+
class Entry(
|
|
38
|
+
val id: Long,
|
|
39
|
+
val role: String,
|
|
40
|
+
val job: String,
|
|
41
|
+
val createdNs: Long,
|
|
42
|
+
) {
|
|
43
|
+
@Volatile var configuredNs: Long = 0
|
|
44
|
+
@Volatile var startedNs: Long = 0
|
|
45
|
+
@Volatile var stoppedNs: Long = 0
|
|
46
|
+
@Volatile var releaseCalledNs: Long = 0
|
|
47
|
+
@Volatile var releasedNs: Long = 0
|
|
48
|
+
@Volatile var failure: String? = null
|
|
49
|
+
|
|
50
|
+
val live: Boolean get() = releasedNs == 0L
|
|
51
|
+
|
|
52
|
+
fun json(nowNs: Long): JSONObject =
|
|
53
|
+
JSONObject()
|
|
54
|
+
.put("id", id)
|
|
55
|
+
.put("role", role)
|
|
56
|
+
.put("job", job)
|
|
57
|
+
.put("ageMs", (nowNs - createdNs) / 1_000_000)
|
|
58
|
+
.put("configuredMs", ms(createdNs, configuredNs))
|
|
59
|
+
.put("startedMs", ms(createdNs, startedNs))
|
|
60
|
+
.put("stoppedMs", ms(createdNs, stoppedNs))
|
|
61
|
+
.put("releaseCalledMs", ms(createdNs, releaseCalledNs))
|
|
62
|
+
.put("releasedMs", ms(createdNs, releasedNs))
|
|
63
|
+
// The gap between asking and finishing. A release that blocks
|
|
64
|
+
// is a different defect from one that was never called.
|
|
65
|
+
.put("releaseTookMs", ms(releaseCalledNs, releasedNs))
|
|
66
|
+
.put("live", live)
|
|
67
|
+
.also { o -> failure?.let { o.put("failure", it) } }
|
|
68
|
+
|
|
69
|
+
private fun ms(from: Long, to: Long): Long =
|
|
70
|
+
if (from == 0L || to == 0L) -1 else (to - from) / 1_000_000
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private val entries = java.util.Collections.synchronizedList(ArrayList<Entry>())
|
|
74
|
+
|
|
75
|
+
@Volatile var peakLive = 0
|
|
76
|
+
private set
|
|
77
|
+
|
|
78
|
+
/** The job codecs are attributed to, set around a preparation. */
|
|
79
|
+
@Volatile var currentJob: String = "none"
|
|
80
|
+
|
|
81
|
+
fun open(role: String): Entry {
|
|
82
|
+
val entry = Entry(nextId.incrementAndGet(), role, currentJob, System.nanoTime())
|
|
83
|
+
entries.add(entry)
|
|
84
|
+
val live = liveCount()
|
|
85
|
+
if (live > peakLive) peakLive = live
|
|
86
|
+
return entry
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun liveCount(): Int = entries.count { it.live }
|
|
90
|
+
|
|
91
|
+
fun liveEntries(): List<Entry> = entries.filter { it.live }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Everything recorded, newest last.
|
|
95
|
+
*
|
|
96
|
+
* Bounded by trimming completed entries once there are plenty: a
|
|
97
|
+
* preparation creates seven or eight, and keeping every one from a long
|
|
98
|
+
* session would grow without limit for no extra evidence.
|
|
99
|
+
*/
|
|
100
|
+
fun snapshot(): JSONObject {
|
|
101
|
+
val nowNs = System.nanoTime()
|
|
102
|
+
val out = JSONArray()
|
|
103
|
+
synchronized(entries) {
|
|
104
|
+
for (e in entries) out.put(e.json(nowNs))
|
|
105
|
+
}
|
|
106
|
+
return JSONObject()
|
|
107
|
+
.put("ok", true)
|
|
108
|
+
.put("live", liveCount())
|
|
109
|
+
.put("peakLive", peakLive)
|
|
110
|
+
.put("total", entries.size)
|
|
111
|
+
.put("entries", out)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun trimCompleted(keep: Int = 32) {
|
|
115
|
+
synchronized(entries) {
|
|
116
|
+
val done = entries.filter { !it.live }
|
|
117
|
+
if (done.size <= keep) return
|
|
118
|
+
entries.removeAll(done.take(done.size - keep).toSet())
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun reset() {
|
|
123
|
+
entries.clear()
|
|
124
|
+
peakLive = 0
|
|
125
|
+
}
|
|
126
|
+
}
|