@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. 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
+ }