@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,1612 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
5
|
+
import com.facebook.react.bridge.ReadableArray
|
|
6
|
+
import com.facebook.react.bridge.WritableMap
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The JS boundary.
|
|
10
|
+
*
|
|
11
|
+
* One process-scoped player, because that is what the architecture is: Media3
|
|
12
|
+
* renders into a presentation surface that outlives any single source, and a
|
|
13
|
+
* map of handles would imply several players sharing one texture.
|
|
14
|
+
*
|
|
15
|
+
* Nothing native is reachable from a finalizer. Teardown is [media3Release] or
|
|
16
|
+
* [invalidate], both explicit and both on the player's own thread — collection
|
|
17
|
+
* running a finalizer on a thread of the runtime's choosing is exactly how the
|
|
18
|
+
* Skia video binding aborts the process.
|
|
19
|
+
*
|
|
20
|
+
* No call here is per frame. Frames reach the renderer through the
|
|
21
|
+
* `__pictorVideoSurface` host object on the UI runtime; these are controls and
|
|
22
|
+
* a throttled snapshot.
|
|
23
|
+
*/
|
|
24
|
+
class PictorVideoModule(reactContext: ReactApplicationContext) :
|
|
25
|
+
NativePictorVideoSpec(reactContext) {
|
|
26
|
+
|
|
27
|
+
override fun getName() = NAME
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Install the JSI probe as a global on the React runtime.
|
|
31
|
+
*
|
|
32
|
+
* From there JS captures it in a worklet closure and Worklets carries the
|
|
33
|
+
* *same* C++ object into the UI runtime — `SerializableHostObject` holds
|
|
34
|
+
* the shared_ptr rather than copying. This is the supported route; the UI
|
|
35
|
+
* runtime pointer itself is not reachable, since `WorkletRuntimeRegistry`
|
|
36
|
+
* exposes only `isRuntimeAlive`.
|
|
37
|
+
*/
|
|
38
|
+
/** Publishes `__pictorVideoSurface` for the UI runtime to drive. */
|
|
39
|
+
override fun installVideoSurface(): Boolean {
|
|
40
|
+
val pointer = reactApplicationContext.javaScriptContextHolder?.get() ?: return false
|
|
41
|
+
if (pointer == 0L) return false
|
|
42
|
+
return runCatching { nativeInstallVideoSurface(pointer) }.getOrDefault(false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Publishes `__pictorOverlayStaging`, for writing export sidecars.
|
|
47
|
+
*
|
|
48
|
+
* The cache directory is handed over here and nowhere else: JS supplies
|
|
49
|
+
* bytes and dimensions, and native decides where they land. A destination
|
|
50
|
+
* that crossed the boundary would let a document string reach a path.
|
|
51
|
+
*/
|
|
52
|
+
override fun installOverlayStaging(): Boolean {
|
|
53
|
+
val pointer = reactApplicationContext.javaScriptContextHolder?.get() ?: return false
|
|
54
|
+
if (pointer == 0L) return false
|
|
55
|
+
val cache = reactApplicationContext.cacheDir?.absolutePath ?: return false
|
|
56
|
+
return runCatching { nativeInstallOverlayStaging(pointer, cache) }.getOrDefault(false)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- Media3 ----------------------------------------------------------
|
|
60
|
+
// Numbers and strings only. ExoPlayer, MediaItem and every other Media3
|
|
61
|
+
// type stops at this boundary.
|
|
62
|
+
|
|
63
|
+
private var media3: Media3Player? = null
|
|
64
|
+
|
|
65
|
+
override fun media3Prepare(uri: String): Boolean =
|
|
66
|
+
runCatching {
|
|
67
|
+
media3?.release()
|
|
68
|
+
media3 = Media3Player.create(reactApplicationContext, uri)
|
|
69
|
+
true
|
|
70
|
+
}.getOrElse {
|
|
71
|
+
lastMedia3Error = it.message ?: it.toString()
|
|
72
|
+
false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Heap attribution: a player with no video output at all. */
|
|
76
|
+
override fun media3PrepareNoSurface(uri: String): Boolean =
|
|
77
|
+
runCatching {
|
|
78
|
+
media3?.release()
|
|
79
|
+
media3 = Media3Player.create(reactApplicationContext, uri, attachSurface = false)
|
|
80
|
+
true
|
|
81
|
+
}.getOrElse { lastMedia3Error = it.message ?: it.toString(); false }
|
|
82
|
+
|
|
83
|
+
/** Heap attribution: reuse the process-scoped player for another source. */
|
|
84
|
+
override fun media3Rebind(uri: String): Boolean =
|
|
85
|
+
runCatching {
|
|
86
|
+
val player = media3 ?: return@runCatching false
|
|
87
|
+
player.rebind(uri)
|
|
88
|
+
true
|
|
89
|
+
}.getOrElse { lastMedia3Error = it.message ?: it.toString(); false }
|
|
90
|
+
|
|
91
|
+
/** Heap attribution: end the source but keep the player alive. */
|
|
92
|
+
override fun media3StopKeepingPlayer() { media3?.stopKeepingPlayer() }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replace the playlist on the process-scoped player.
|
|
96
|
+
*
|
|
97
|
+
* Creates the player if there is not one yet, so a host can open a
|
|
98
|
+
* multi-clip project without a separate prepare step.
|
|
99
|
+
*/
|
|
100
|
+
override fun media3SetPlaylist(
|
|
101
|
+
uris: ReadableArray,
|
|
102
|
+
bounds: ReadableArray,
|
|
103
|
+
startWindow: Double,
|
|
104
|
+
startPositionUs: Double,
|
|
105
|
+
): Boolean =
|
|
106
|
+
runCatching {
|
|
107
|
+
val sources = ArrayList<String>(uris.size())
|
|
108
|
+
for (i in 0 until uris.size()) sources.add(uris.getString(i) ?: "")
|
|
109
|
+
val numbers = DoubleArray(bounds.size()) { bounds.getDouble(it) }
|
|
110
|
+
if (sources.isEmpty()) {
|
|
111
|
+
media3?.stopKeepingPlayer()
|
|
112
|
+
return@runCatching true
|
|
113
|
+
}
|
|
114
|
+
val player = media3 ?: Media3Player.create(
|
|
115
|
+
reactApplicationContext,
|
|
116
|
+
sources.first(),
|
|
117
|
+
).also { media3 = it }
|
|
118
|
+
player.setPlaylist(sources, numbers, startWindow.toInt(), startPositionUs.toLong())
|
|
119
|
+
true
|
|
120
|
+
}.getOrElse {
|
|
121
|
+
// Logged as well as stored. A playlist that fails to load shows up
|
|
122
|
+
// as a black preview and nothing else, and "no output and no
|
|
123
|
+
// explanation" is the hardest kind of failure to chase.
|
|
124
|
+
lastMedia3Error = it.message ?: it.toString()
|
|
125
|
+
android.util.Log.e(NAME, "media3SetPlaylist failed: $lastMedia3Error", it)
|
|
126
|
+
false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Seek in the player's own coordinates: window index plus offset. */
|
|
130
|
+
override fun media3SeekToWindow(windowIndex: Double, positionUs: Double) {
|
|
131
|
+
media3?.seekToWindow(windowIndex.toInt(), positionUs.toLong())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- V3.3 export gate -------------------------------------------------
|
|
135
|
+
// Experimental and deliberately unlike the rest: one URI in, a JSON report
|
|
136
|
+
// out. Not an exporter, not wired to VideoExporter, and not a shape anything
|
|
137
|
+
// should depend on.
|
|
138
|
+
|
|
139
|
+
private var gate: ExportGate? = null
|
|
140
|
+
|
|
141
|
+
/** One worker: the native module runs one export per process. */
|
|
142
|
+
private val exportExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
|
143
|
+
|
|
144
|
+
@androidx.media3.common.util.UnstableApi
|
|
145
|
+
override fun exportGateRun(uri: String, withSkia: Boolean, timeoutSeconds: Double): String {
|
|
146
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
147
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
148
|
+
val outcome = runCatching { running.run(uri, withSkia, timeoutSeconds.toLong()) }
|
|
149
|
+
.getOrElse {
|
|
150
|
+
return org.json.JSONObject()
|
|
151
|
+
.put("ok", false)
|
|
152
|
+
.put("error", "gate threw: ${'$'}{it.message}")
|
|
153
|
+
.toString()
|
|
154
|
+
}
|
|
155
|
+
return report(outcome, running)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@androidx.media3.common.util.UnstableApi
|
|
159
|
+
private fun report(outcome: ExportGate.Outcome, running: ExportGate): String {
|
|
160
|
+
return org.json.JSONObject()
|
|
161
|
+
.put("ok", outcome.ok)
|
|
162
|
+
.put("uri", outcome.uri)
|
|
163
|
+
.put("error", outcome.error)
|
|
164
|
+
.put("elapsedMs", outcome.elapsedMs)
|
|
165
|
+
.put("fileBytes", outcome.fileBytes)
|
|
166
|
+
.put("durationMs", outcome.durationMs)
|
|
167
|
+
.put("width", outcome.width)
|
|
168
|
+
.put("height", outcome.height)
|
|
169
|
+
.put("videoFrameCount", outcome.videoFrameCount)
|
|
170
|
+
.put("hasAudio", outcome.hasAudio)
|
|
171
|
+
.put("videoMimeType", outcome.videoMimeType)
|
|
172
|
+
.put("audioMimeType", outcome.audioMimeType)
|
|
173
|
+
.put("colorInfo", outcome.colorInfo)
|
|
174
|
+
.put("glErrors", outcome.glErrors)
|
|
175
|
+
.put("effectFrames", outcome.effectFrames)
|
|
176
|
+
.put("effectTimestampsUs", outcome.effectTimestampsUs)
|
|
177
|
+
.put("boundary", outcome.boundary)
|
|
178
|
+
.put("nativeHeapBytes", outcome.nativeHeapBytes)
|
|
179
|
+
.put("javaHeapBytes", outcome.javaHeapBytes)
|
|
180
|
+
.put("callerThread", outcome.callerThread)
|
|
181
|
+
.put("refusals", running.refusalCount())
|
|
182
|
+
.put("started", running.startedCount())
|
|
183
|
+
.put("releases", running.releaseCount())
|
|
184
|
+
.put("completed", running.completedCount())
|
|
185
|
+
.put("failures", running.failureCount())
|
|
186
|
+
.put("lifecycle", outcome.lifecycle)
|
|
187
|
+
.put("programTrace", outcome.programTrace)
|
|
188
|
+
.put("muxTrace", outcome.muxTrace)
|
|
189
|
+
.put("overlayUploads", outcome.overlayUploads)
|
|
190
|
+
.put("overlayTexturesFreed", outcome.overlayTexturesFreed)
|
|
191
|
+
.toString()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@androidx.media3.common.util.UnstableApi
|
|
195
|
+
override fun exportGateRunScene(
|
|
196
|
+
uri: String,
|
|
197
|
+
sceneJson: String,
|
|
198
|
+
timeoutSeconds: Double,
|
|
199
|
+
failAfterFrames: Double,
|
|
200
|
+
): String {
|
|
201
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
202
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
203
|
+
val outcome = runCatching {
|
|
204
|
+
running.runScene(uri, sceneJson, timeoutSeconds.toLong(), failAfterFrames.toInt())
|
|
205
|
+
}
|
|
206
|
+
.getOrElse {
|
|
207
|
+
return org.json.JSONObject()
|
|
208
|
+
.put("ok", false)
|
|
209
|
+
.put("error", "gate threw: ${'$'}{it.message}")
|
|
210
|
+
.toString()
|
|
211
|
+
}
|
|
212
|
+
return report(outcome, running)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@androidx.media3.common.util.UnstableApi
|
|
216
|
+
override fun exportGateRunProgram(
|
|
217
|
+
uris: ReadableArray,
|
|
218
|
+
programJson: String,
|
|
219
|
+
timeoutSeconds: Double,
|
|
220
|
+
): String {
|
|
221
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
222
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
223
|
+
val list = (0 until uris.size()).mapNotNull { uris.getString(it) }
|
|
224
|
+
val outcome = runCatching { running.runProgram(list, programJson, timeoutSeconds.toLong()) }
|
|
225
|
+
.getOrElse {
|
|
226
|
+
return org.json.JSONObject().put("ok", false)
|
|
227
|
+
.put("error", "gate threw: " + it.message).toString()
|
|
228
|
+
}
|
|
229
|
+
return report(outcome, running)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@androidx.media3.common.util.UnstableApi
|
|
233
|
+
override fun exportGateReleaseTemporary(): Double {
|
|
234
|
+
// A sweep, not scoped cleanup: it takes temporaries a caller may still
|
|
235
|
+
// own. Production uses `exportReleaseTemporaryOutput`, which removes one
|
|
236
|
+
// path and refuses any it did not create. -1 is "not in this build",
|
|
237
|
+
// distinguishable from a sweep that found nothing.
|
|
238
|
+
if (!Diagnostics.ENABLED) return -1.0
|
|
239
|
+
return (gate?.releaseTemporary() ?: 0).toDouble()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@androidx.media3.common.util.UnstableApi
|
|
243
|
+
override fun exportGateRunItems(
|
|
244
|
+
itemsJson: String,
|
|
245
|
+
programJson: String,
|
|
246
|
+
timeoutSeconds: Double,
|
|
247
|
+
): String {
|
|
248
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
249
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
250
|
+
val parsed = try {
|
|
251
|
+
val array = org.json.JSONArray(itemsJson)
|
|
252
|
+
(0 until array.length()).map {
|
|
253
|
+
val o = array.getJSONObject(it)
|
|
254
|
+
ExportGate.Item(
|
|
255
|
+
o.getString("uri"),
|
|
256
|
+
o.optLong("startMs", 0),
|
|
257
|
+
o.optLong("endMs", 0),
|
|
258
|
+
o.optDouble("speed", 1.0).toFloat(),
|
|
259
|
+
o.optBoolean("removeAudio", false),
|
|
260
|
+
o.optString("speedMode", "preserve"),
|
|
261
|
+
o.optLong("durationUs", 0),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
} catch (e: Exception) {
|
|
265
|
+
return org.json.JSONObject().put("ok", false)
|
|
266
|
+
.put("error", "items: " + e.message).toString()
|
|
267
|
+
}
|
|
268
|
+
// Sequence-level, not per item: reading it off the first entry alone
|
|
269
|
+
// would silently ignore it anywhere else in the list.
|
|
270
|
+
val force = try {
|
|
271
|
+
val array = org.json.JSONArray(itemsJson)
|
|
272
|
+
(0 until array.length()).any { array.optJSONObject(it)?.optBoolean("forceAudioTrack", false) == true }
|
|
273
|
+
} catch (e: Exception) {
|
|
274
|
+
false
|
|
275
|
+
}
|
|
276
|
+
val outcome = runCatching { running.runItems(parsed, programJson, timeoutSeconds.toLong(), force) }
|
|
277
|
+
.getOrElse {
|
|
278
|
+
return org.json.JSONObject().put("ok", false)
|
|
279
|
+
.put("error", "gate threw: " + it.message).toString()
|
|
280
|
+
}
|
|
281
|
+
return report(outcome, running)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@androidx.media3.common.util.UnstableApi
|
|
285
|
+
override fun exportGateRunShards(
|
|
286
|
+
shardsJson: String,
|
|
287
|
+
programJson: String,
|
|
288
|
+
timeoutSeconds: Double,
|
|
289
|
+
overlaysJson: String,
|
|
290
|
+
): String {
|
|
291
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
292
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
293
|
+
val parsed = try {
|
|
294
|
+
val array = org.json.JSONArray(shardsJson)
|
|
295
|
+
(0 until array.length()).map {
|
|
296
|
+
val o = array.getJSONObject(it)
|
|
297
|
+
ExportGate.Shard(
|
|
298
|
+
o.getString("uri"),
|
|
299
|
+
o.optLong("startMs", 0),
|
|
300
|
+
o.optLong("endMs", 0),
|
|
301
|
+
o.optDouble("speed", 1.0).toFloat(),
|
|
302
|
+
o.optLong("timelineOffsetUs", 0),
|
|
303
|
+
o.optBoolean("forceAudioTrack", false),
|
|
304
|
+
o.optInt("frameDropFps", 0),
|
|
305
|
+
o.optLong("encoderRepeatUs", 0),
|
|
306
|
+
o.optJSONArray("emitsPerInput")?.let { a ->
|
|
307
|
+
IntArray(a.length()) { i -> a.getInt(i) }
|
|
308
|
+
},
|
|
309
|
+
o.optJSONArray("outputPtsUs")?.let { a ->
|
|
310
|
+
LongArray(a.length()) { i -> a.getLong(i) }
|
|
311
|
+
},
|
|
312
|
+
o.optInt("cadenceFailAfterEmits", -1),
|
|
313
|
+
o.optLong("spanUs", 0),
|
|
314
|
+
o.optBoolean("removeAudio", false),
|
|
315
|
+
o.optString("speedMode", "preserve"),
|
|
316
|
+
o.optInt("overlayFailAfterDraws", -1),
|
|
317
|
+
o.optBoolean("failConcat", false),
|
|
318
|
+
o.optInt("cadenceStallAfterEmits", -1),
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
} catch (e: Exception) {
|
|
322
|
+
return org.json.JSONObject().put("ok", false)
|
|
323
|
+
.put("error", "shards: " + e.message).toString()
|
|
324
|
+
}
|
|
325
|
+
val outcome = runCatching {
|
|
326
|
+
running.runShards(parsed, programJson, timeoutSeconds.toLong(), overlaysJson)
|
|
327
|
+
}
|
|
328
|
+
.getOrElse {
|
|
329
|
+
return org.json.JSONObject().put("ok", false)
|
|
330
|
+
.put("error", "gate threw: " + it.message).toString()
|
|
331
|
+
}
|
|
332
|
+
return report(outcome, running)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
override fun exportGateSampleHashes(uri: String): String {
|
|
336
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
337
|
+
return GateFrames.sampleHashes(reactApplicationContext, uri)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@androidx.media3.common.util.UnstableApi
|
|
341
|
+
override fun exportGateEncodeStill(pngBase64: String, frames: Double): String {
|
|
342
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
343
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
344
|
+
val bytes = try {
|
|
345
|
+
android.util.Base64.decode(pngBase64, android.util.Base64.DEFAULT)
|
|
346
|
+
} catch (e: Exception) {
|
|
347
|
+
return org.json.JSONObject().put("ok", false).put("error", "base64: " + e.message).toString()
|
|
348
|
+
}
|
|
349
|
+
val outcome = runCatching { running.runStill(bytes, frames.toInt()) }
|
|
350
|
+
.getOrElse {
|
|
351
|
+
return org.json.JSONObject().put("ok", false)
|
|
352
|
+
.put("error", "gate threw: " + it.message).toString()
|
|
353
|
+
}
|
|
354
|
+
return report(outcome, running)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
override fun exportGateGeometry(
|
|
358
|
+
leftUri: String,
|
|
359
|
+
leftUs: Double,
|
|
360
|
+
rightUri: String,
|
|
361
|
+
rightUs: Double,
|
|
362
|
+
maskThreshold: Double,
|
|
363
|
+
): String = GateFrames.geometry(
|
|
364
|
+
reactApplicationContext, leftUri, leftUs.toLong(), rightUri, rightUs.toLong(), maskThreshold.toInt(),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
override fun exportGateFramePng(uri: String, timeUs: Double): String {
|
|
368
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
369
|
+
return GateFrames.framePng(reactApplicationContext, uri, timeUs.toLong())
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Run an export off the bridge, so it can be cancelled while it runs.
|
|
374
|
+
*
|
|
375
|
+
* A blocking export holds the JS thread for the whole encode, which makes
|
|
376
|
+
* cancellation impossible by construction — the message asking it to stop
|
|
377
|
+
* cannot be delivered until the thing it would stop has finished. So the
|
|
378
|
+
* work moves to a worker and the result arrives on a promise.
|
|
379
|
+
*/
|
|
380
|
+
@androidx.media3.common.util.UnstableApi
|
|
381
|
+
override fun exportRunProjectAsync(
|
|
382
|
+
jobId: String,
|
|
383
|
+
shardsJson: String,
|
|
384
|
+
programJson: String,
|
|
385
|
+
overlaysJson: String,
|
|
386
|
+
mixProgramJson: String,
|
|
387
|
+
mixUrisJson: String,
|
|
388
|
+
timeoutSeconds: Double,
|
|
389
|
+
destinationJson: String,
|
|
390
|
+
promise: com.facebook.react.bridge.Promise,
|
|
391
|
+
) {
|
|
392
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
393
|
+
if (!running.beginJob(jobId)) {
|
|
394
|
+
promise.resolve(
|
|
395
|
+
org.json.JSONObject().put("ok", false)
|
|
396
|
+
.put("error", "an export is already running").toString(),
|
|
397
|
+
)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
exportExecutor.execute {
|
|
401
|
+
val outcome = try {
|
|
402
|
+
runProjectInternal(
|
|
403
|
+
shardsJson, programJson, overlaysJson, mixProgramJson, mixUrisJson, timeoutSeconds,
|
|
404
|
+
destinationJson,
|
|
405
|
+
)
|
|
406
|
+
} catch (e: Throwable) {
|
|
407
|
+
org.json.JSONObject().put("ok", false)
|
|
408
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
409
|
+
} finally {
|
|
410
|
+
running.endJob()
|
|
411
|
+
}
|
|
412
|
+
promise.resolve(outcome)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Counted work plus the beat total, read by the JavaScript progress pump. */
|
|
417
|
+
@androidx.media3.common.util.UnstableApi
|
|
418
|
+
override fun exportProgressSnapshot(): String =
|
|
419
|
+
gate?.progressSnapshotJson()
|
|
420
|
+
?: org.json.JSONObject()
|
|
421
|
+
.put("shards", org.json.JSONArray())
|
|
422
|
+
.put("audioSamples", 0)
|
|
423
|
+
.put("audioPlanned", 0)
|
|
424
|
+
.put("muxed", false)
|
|
425
|
+
.put("beats", 0)
|
|
426
|
+
.toString()
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Prove the destination opens before anyone is told the export is done.
|
|
430
|
+
*
|
|
431
|
+
* Progress reaching 1 over a file that cannot be read is a worse lie than a
|
|
432
|
+
* bar that sits at 97 for a moment, and the encoder finishing is not the
|
|
433
|
+
* same event as the container being closed and parseable.
|
|
434
|
+
*/
|
|
435
|
+
/**
|
|
436
|
+
* What a picked audio file contains, before it may enter a project.
|
|
437
|
+
*
|
|
438
|
+
* See [AudioProbe]: the mix compiler has no decoder and takes the
|
|
439
|
+
* document's word for whether a source has audio, so this is the only
|
|
440
|
+
* thing that can establish it.
|
|
441
|
+
*/
|
|
442
|
+
/**
|
|
443
|
+
* Prepare the audio a preview plays, off the JS thread.
|
|
444
|
+
*
|
|
445
|
+
* The same mixer an export runs, over the same compiled program — see
|
|
446
|
+
* [PreviewMix]. Asynchronous because it decodes, re-times and encodes every
|
|
447
|
+
* contributing source: this is preparation, not playback.
|
|
448
|
+
*/
|
|
449
|
+
override fun previewMixPrepare(
|
|
450
|
+
jobId: String,
|
|
451
|
+
programJson: String,
|
|
452
|
+
urisJson: String,
|
|
453
|
+
promise: com.facebook.react.bridge.Promise,
|
|
454
|
+
) {
|
|
455
|
+
PreviewMix.submit {
|
|
456
|
+
val outcome = try {
|
|
457
|
+
PreviewMix.prepare(reactApplicationContext, jobId, programJson, urisJson)
|
|
458
|
+
} catch (e: Throwable) {
|
|
459
|
+
org.json.JSONObject().put("ok", false)
|
|
460
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
461
|
+
}
|
|
462
|
+
promise.resolve(outcome)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Gate-only: prepare a preview mix whose only source is the marker fixture,
|
|
468
|
+
* then decode the result and report where the markers actually landed.
|
|
469
|
+
*
|
|
470
|
+
* The end-to-end alignment question. The encoder baseline proves the encoder
|
|
471
|
+
* is unchanged; this proves the file a player is handed puts the sound where
|
|
472
|
+
* the timeline says it is.
|
|
473
|
+
*/
|
|
474
|
+
override fun previewAlignmentGate(startUs: Double): String {
|
|
475
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
476
|
+
return try {
|
|
477
|
+
val dir = reactApplicationContext.cacheDir
|
|
478
|
+
// WAV, not AAC: an uncompressed source has no priming of its own,
|
|
479
|
+
// so the only delay left to measure is the one under test.
|
|
480
|
+
val source = java.io.File(dir, "pictor-align-source.wav")
|
|
481
|
+
val written = EncoderGate.writeWavFixture(reactApplicationContext, "markers", source)
|
|
482
|
+
if (!written.optBoolean("ok", false)) return written.toString()
|
|
483
|
+
|
|
484
|
+
val durationUs = written.getLong("durationUs")
|
|
485
|
+
val start = startUs.toLong()
|
|
486
|
+
val spanId = "align"
|
|
487
|
+
val program = org.json.JSONObject()
|
|
488
|
+
.put("version", 1)
|
|
489
|
+
.put("rule", "sum-then-saturate")
|
|
490
|
+
.put("durationUs", start + durationUs)
|
|
491
|
+
.put(
|
|
492
|
+
"spans",
|
|
493
|
+
org.json.JSONArray().put(
|
|
494
|
+
org.json.JSONObject()
|
|
495
|
+
.put("id", spanId)
|
|
496
|
+
.put("startUs", start)
|
|
497
|
+
.put("endUs", start + durationUs)
|
|
498
|
+
.put("sourceInUs", 0)
|
|
499
|
+
.put("sourceOutUs", durationUs)
|
|
500
|
+
.put("speed", 1.0)
|
|
501
|
+
.put("gain", 1.0)
|
|
502
|
+
.put("silent", false)
|
|
503
|
+
.put("ramps", org.json.JSONArray()),
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
val uris = org.json.JSONObject().put(spanId, written.getString("uri"))
|
|
507
|
+
|
|
508
|
+
val raw = java.io.File(dir, "pictor-align-raw.m4a")
|
|
509
|
+
val result = org.json.JSONObject(
|
|
510
|
+
AudioMixer.mix(reactApplicationContext, program.toString(), uris.toString(), raw),
|
|
511
|
+
)
|
|
512
|
+
if (!result.optBoolean("ok", false)) return result.toString()
|
|
513
|
+
|
|
514
|
+
/*
|
|
515
|
+
Through the correction, because that is what preview publishes.
|
|
516
|
+
|
|
517
|
+
The first version of this gate measured `raw` and reported the
|
|
518
|
+
mix's priming as still present — which was true of that file and
|
|
519
|
+
false of the one a player is given. A gate that measures a
|
|
520
|
+
different artifact from the product is worse than no gate: it
|
|
521
|
+
reported a defect that had already been fixed.
|
|
522
|
+
*/
|
|
523
|
+
val mix = java.io.File(dir, "pictor-align-mix.m4a")
|
|
524
|
+
val trimmed = org.json.JSONObject(
|
|
525
|
+
PrimingTrim.shiftAudioFile(reactApplicationContext, raw, mix).toString(),
|
|
526
|
+
)
|
|
527
|
+
runCatching { raw.delete() }
|
|
528
|
+
if (!trimmed.optBoolean("ok", false)) return trimmed.toString()
|
|
529
|
+
|
|
530
|
+
// Expected timeline positions: the source markers, shifted by where
|
|
531
|
+
// the span was placed.
|
|
532
|
+
val expected = org.json.JSONArray()
|
|
533
|
+
val markers = written.getJSONArray("markersMs")
|
|
534
|
+
for (i in 0 until markers.length()) {
|
|
535
|
+
expected.put(markers.getInt(i) + (start / 1000).toInt())
|
|
536
|
+
}
|
|
537
|
+
EncoderGate.alignment(reactApplicationContext, mix, expected)
|
|
538
|
+
.put("startUs", start)
|
|
539
|
+
.put("sourceDurationUs", durationUs)
|
|
540
|
+
.put("priming", trimmed)
|
|
541
|
+
.toString()
|
|
542
|
+
} catch (e: Throwable) {
|
|
543
|
+
org.json.JSONObject().put("ok", false)
|
|
544
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Gate-only: the objective Music gates that can be measured from files.
|
|
550
|
+
*
|
|
551
|
+
* Preview and export mix from the same program through the same code, so
|
|
552
|
+
* equality is checked by decoding both and comparing samples — not by
|
|
553
|
+
* trusting that they share a function. Source formats are exercised at a
|
|
554
|
+
* zero and a non-zero trim, because a decoder's behaviour at the head of a
|
|
555
|
+
* file is not its behaviour after a seek.
|
|
556
|
+
*/
|
|
557
|
+
override fun musicGates(): String {
|
|
558
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
559
|
+
val results = org.json.JSONArray()
|
|
560
|
+
val dir = reactApplicationContext.cacheDir
|
|
561
|
+
val wav = java.io.File(dir, "pictor-g-src.wav")
|
|
562
|
+
val aac = java.io.File(dir, "pictor-g-src.m4a")
|
|
563
|
+
return try {
|
|
564
|
+
val w = EncoderGate.writeWavFixture(reactApplicationContext, "markers", wav)
|
|
565
|
+
val a = EncoderGate.writeFixture(reactApplicationContext, "markers", aac)
|
|
566
|
+
if (!w.optBoolean("ok", false) || !a.optBoolean("ok", false)) {
|
|
567
|
+
return org.json.JSONObject().put("ok", false).put("error", "fixtures").toString()
|
|
568
|
+
}
|
|
569
|
+
val durationUs = w.getLong("durationUs")
|
|
570
|
+
|
|
571
|
+
fun programFor(uri: String, sourceInUs: Long, endUs: Long): Pair<String, String> {
|
|
572
|
+
val span = org.json.JSONObject()
|
|
573
|
+
.put("id", "g").put("startUs", 0).put("endUs", endUs - sourceInUs)
|
|
574
|
+
.put("sourceInUs", sourceInUs).put("sourceOutUs", endUs)
|
|
575
|
+
.put("speed", 1.0).put("gain", 1.0).put("silent", false)
|
|
576
|
+
.put("ramps", org.json.JSONArray())
|
|
577
|
+
val program = org.json.JSONObject()
|
|
578
|
+
.put("version", 1).put("rule", "sum-then-saturate")
|
|
579
|
+
.put("durationUs", endUs - sourceInUs)
|
|
580
|
+
.put("spans", org.json.JSONArray().put(span))
|
|
581
|
+
return program.toString() to org.json.JSONObject().put("g", uri).toString()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Mix, publish through the shared correction, decode. */
|
|
585
|
+
fun publishAndDecode(programJson: String, urisJson: String, tag: String): org.json.JSONObject {
|
|
586
|
+
val raw = java.io.File(dir, "pictor-g-$tag-raw.m4a")
|
|
587
|
+
val pub = java.io.File(dir, "pictor-g-$tag.m4a")
|
|
588
|
+
val mixed = org.json.JSONObject(
|
|
589
|
+
AudioMixer.mix(reactApplicationContext, programJson, urisJson, raw),
|
|
590
|
+
)
|
|
591
|
+
if (!mixed.optBoolean("ok", false)) {
|
|
592
|
+
return org.json.JSONObject().put("ok", false).put("error", mixed.optString("error"))
|
|
593
|
+
}
|
|
594
|
+
val endUs = org.json.JSONObject(programJson).optLong("durationUs", 0)
|
|
595
|
+
val shifted = PrimingTrim.shiftAudioFile(reactApplicationContext, raw, pub, endUs)
|
|
596
|
+
runCatching { raw.delete() }
|
|
597
|
+
if (!shifted.optBoolean("ok", false)) {
|
|
598
|
+
return org.json.JSONObject().put("ok", false).put("error", shifted.optString("error"))
|
|
599
|
+
}
|
|
600
|
+
val pcm = EncoderGate.decodeFile(pub)
|
|
601
|
+
val out = EncoderGate.describeStage(tag, pcm)
|
|
602
|
+
.put("container", PrimingTrim.inspect(pub))
|
|
603
|
+
.put("tailUnitsDropped", shifted.optInt("tailUnitsDropped"))
|
|
604
|
+
.put("expectedFrames", endUs * AudioMixer.SAMPLE_RATE / 1_000_000L)
|
|
605
|
+
.put("fileBytes", pub.length())
|
|
606
|
+
runCatching { pub.delete() }
|
|
607
|
+
return out.put("ok", true)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Tail and duration, now that the compiled end reaches publication.
|
|
611
|
+
val (p0, u0) = programFor(w.getString("uri"), 0, durationUs)
|
|
612
|
+
results.put(publishAndDecode(p0, u0, "wav-from-zero"))
|
|
613
|
+
|
|
614
|
+
// The same program twice: preview and export mix identically, so
|
|
615
|
+
// decoding both must give the same samples.
|
|
616
|
+
val first = publishAndDecode(p0, u0, "equality-a")
|
|
617
|
+
val second = publishAndDecode(p0, u0, "equality-b")
|
|
618
|
+
results.put(
|
|
619
|
+
org.json.JSONObject()
|
|
620
|
+
.put("stage", "preview-export-equality")
|
|
621
|
+
.put("framesEqual", first.optInt("frames") == second.optInt("frames"))
|
|
622
|
+
.put("rmsEqual", first.optDouble("rms") == second.optDouble("rms"))
|
|
623
|
+
.put("markersEqual", first.optJSONArray("markersMs").toString() == second.optJSONArray("markersMs").toString())
|
|
624
|
+
.put("headEqual", first.optDouble("peakFirst60ms") == second.optDouble("peakFirst60ms"))
|
|
625
|
+
.put("bytesEqual", first.optLong("fileBytes") == second.optLong("fileBytes")),
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
// AAC source, from zero and after a non-zero trim.
|
|
629
|
+
val (p1, u1) = programFor(a.getString("uri"), 0, durationUs)
|
|
630
|
+
results.put(publishAndDecode(p1, u1, "aac-from-zero"))
|
|
631
|
+
val (p2, u2) = programFor(a.getString("uri"), 2_000_000, durationUs)
|
|
632
|
+
results.put(publishAndDecode(p2, u2, "aac-from-2s"))
|
|
633
|
+
|
|
634
|
+
// MP3 source, likewise — the format whose declared delay once made
|
|
635
|
+
// the head check stricter than declaring nothing.
|
|
636
|
+
val mp3 = "android.resource://dev.pictor.example/raw/music_b"
|
|
637
|
+
val (p3, u3) = programFor(mp3, 0, 6_000_000)
|
|
638
|
+
results.put(publishAndDecode(p3, u3, "mp3-from-zero"))
|
|
639
|
+
val (p4, u4) = programFor(mp3, 2_000_000, 6_000_000)
|
|
640
|
+
results.put(publishAndDecode(p4, u4, "mp3-from-2s"))
|
|
641
|
+
|
|
642
|
+
org.json.JSONObject().put("ok", true).put("gates", results).toString()
|
|
643
|
+
} catch (e: Throwable) {
|
|
644
|
+
org.json.JSONObject().put("ok", false)
|
|
645
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
646
|
+
} finally {
|
|
647
|
+
runCatching { wav.delete() }
|
|
648
|
+
runCatching { aac.delete() }
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Gate-only: where the samples go, stage by stage.
|
|
654
|
+
*
|
|
655
|
+
* One deterministic WAV source with a marker at sample zero, carried
|
|
656
|
+
* through mixing, encoding, decoding and both candidate corrections. Each
|
|
657
|
+
* stage is measured the same way, so "the marker moved" and "the marker
|
|
658
|
+
* went" can finally be told apart.
|
|
659
|
+
*/
|
|
660
|
+
override fun primingAttribution(): String {
|
|
661
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
662
|
+
val out = org.json.JSONArray()
|
|
663
|
+
val dir = reactApplicationContext.cacheDir
|
|
664
|
+
val source = java.io.File(dir, "pictor-attr-source.wav")
|
|
665
|
+
val raw = java.io.File(dir, "pictor-attr-raw.m4a")
|
|
666
|
+
val published = java.io.File(dir, "pictor-attr-published.m4a")
|
|
667
|
+
return try {
|
|
668
|
+
val written = EncoderGate.writeWavFixture(reactApplicationContext, "markers", source)
|
|
669
|
+
if (!written.optBoolean("ok", false)) return written.toString()
|
|
670
|
+
val durationUs = written.getLong("durationUs")
|
|
671
|
+
|
|
672
|
+
// 1. The source, before anything touches it.
|
|
673
|
+
out.put(EncoderGate.describeStage("1-source-pcm", EncoderGate.pcmFor("markers")))
|
|
674
|
+
|
|
675
|
+
val spanId = "attr"
|
|
676
|
+
val program = org.json.JSONObject()
|
|
677
|
+
.put("version", 1).put("rule", "sum-then-saturate").put("durationUs", durationUs)
|
|
678
|
+
.put(
|
|
679
|
+
"spans",
|
|
680
|
+
org.json.JSONArray().put(
|
|
681
|
+
org.json.JSONObject()
|
|
682
|
+
.put("id", spanId).put("startUs", 0).put("endUs", durationUs)
|
|
683
|
+
.put("sourceInUs", 0).put("sourceOutUs", durationUs)
|
|
684
|
+
.put("speed", 1.0).put("gain", 1.0).put("silent", false)
|
|
685
|
+
.put("ramps", org.json.JSONArray()),
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
val uris = org.json.JSONObject().put(spanId, written.getString("uri"))
|
|
689
|
+
val mixed = org.json.JSONObject(
|
|
690
|
+
AudioMixer.mix(reactApplicationContext, program.toString(), uris.toString(), raw),
|
|
691
|
+
)
|
|
692
|
+
if (!mixed.optBoolean("ok", false)) return mixed.toString()
|
|
693
|
+
|
|
694
|
+
// 2. What the encoder was handed, de-click envelope included.
|
|
695
|
+
AudioMixer.lastMixedPcm?.let {
|
|
696
|
+
out.put(EncoderGate.describeStage("2-mixed-pcm-before-encode", it))
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// 3. The encoded file, decoded from its very beginning.
|
|
700
|
+
val fullDecode = EncoderGate.decodeFile(raw)
|
|
701
|
+
out.put(EncoderGate.describeStage("3-encoded-decoded-full", fullDecode))
|
|
702
|
+
|
|
703
|
+
// 4. The same decode with exactly the measured priming removed, in
|
|
704
|
+
// memory. Sample-exact, and it never loses overlap context
|
|
705
|
+
// because the decoder saw every access unit.
|
|
706
|
+
val priming = PrimingTrim.measureFor(reactApplicationContext, raw.absolutePath)
|
|
707
|
+
val drop = minOf(fullDecode.size, priming * AudioMixer.CHANNELS)
|
|
708
|
+
val sampleExact = fullDecode.copyOfRange(drop, fullDecode.size)
|
|
709
|
+
out.put(
|
|
710
|
+
EncoderGate.describeStage("4-decoded-minus-measured-priming", sampleExact)
|
|
711
|
+
.put("primingFramesRemoved", priming),
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
// 5. What is published: every unit kept, the start moved instead.
|
|
715
|
+
val shiftedFile = java.io.File(dir, "pictor-attr-shifted.m4a")
|
|
716
|
+
val shifted = PrimingTrim.shiftAudioFile(reactApplicationContext, raw, shiftedFile)
|
|
717
|
+
if (shifted.optBoolean("ok", false)) {
|
|
718
|
+
out.put(
|
|
719
|
+
EncoderGate.describeStage("5-published-timestamp-shifted", EncoderGate.decodeFile(shiftedFile))
|
|
720
|
+
.put("negativePtsUnits", shifted.optInt("negativePtsUnits"))
|
|
721
|
+
.put("firstPtsUs", shifted.optLong("firstPtsUs"))
|
|
722
|
+
.put("container", PrimingTrim.inspect(shiftedFile)),
|
|
723
|
+
)
|
|
724
|
+
} else {
|
|
725
|
+
out.put(org.json.JSONObject().put("stage", "5-published-timestamp-shifted").put("failed", shifted))
|
|
726
|
+
}
|
|
727
|
+
runCatching { shiftedFile.delete() }
|
|
728
|
+
|
|
729
|
+
org.json.JSONObject()
|
|
730
|
+
.put("ok", true)
|
|
731
|
+
.put("expectedMarkersMs", EncoderGate.expectedMarkersMs())
|
|
732
|
+
.put("measuredPrimingFrames", priming)
|
|
733
|
+
.put("stages", out)
|
|
734
|
+
.toString()
|
|
735
|
+
} catch (e: Throwable) {
|
|
736
|
+
org.json.JSONObject().put("ok", false)
|
|
737
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
738
|
+
} finally {
|
|
739
|
+
runCatching { source.delete() }
|
|
740
|
+
runCatching { raw.delete() }
|
|
741
|
+
runCatching { published.delete() }
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Gate-only: encode a fixed PCM fixture and describe the result completely. */
|
|
746
|
+
override fun encoderGateRun(fixture: String, maxInputSize: Double): String =
|
|
747
|
+
EncoderGate.run(reactApplicationContext, fixture, maxInputSize.toInt()).toString()
|
|
748
|
+
|
|
749
|
+
override fun previewMixCancel(): String = PreviewMix.cancel()
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Every codec mix preparation owns, and what became of it.
|
|
753
|
+
*
|
|
754
|
+
* Read-only accounting. The preview video decoder is deliberately absent:
|
|
755
|
+
* it belongs to the player and lives as long as the screen, so counting it
|
|
756
|
+
* would make "live after publication" always true.
|
|
757
|
+
*/
|
|
758
|
+
override fun previewCodecLedger(): String =
|
|
759
|
+
CodecLedger.snapshot()
|
|
760
|
+
// What a bounds gate actually asks: not just codecs, but how many
|
|
761
|
+
// second players and preparation jobs a run left behind.
|
|
762
|
+
.put("players", PreviewAudioPlayer.instanceCounters())
|
|
763
|
+
.put("jobs", PreviewMix.jobCounters())
|
|
764
|
+
.toString()
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
/** Gate-only: which parts of the music preview exist. @see PreviewConfig */
|
|
768
|
+
override fun previewConfigSet(json: String): String = PreviewConfig.apply(json).toString()
|
|
769
|
+
|
|
770
|
+
/** What the configuration currently is. Read-only. */
|
|
771
|
+
override fun previewConfigGet(): String = PreviewConfig.current().put("ok", true).toString()
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* How many frames the pipeline has offered, queued and drawn.
|
|
776
|
+
*
|
|
777
|
+
* Read-only accounting, so it stays available in release for the same
|
|
778
|
+
* reason the ledgers do — and specifically so that "is the instrument
|
|
779
|
+
* disturbing the picture?" can be asked *without* the instrument. Volatile
|
|
780
|
+
* reads only: the counters that needed the frame lock were what froze the
|
|
781
|
+
* renderer when sampled at 40 Hz.
|
|
782
|
+
*/
|
|
783
|
+
override fun spikeStart(specJson: String): String =
|
|
784
|
+
CompositionSpike.start(reactApplicationContext, specJson)
|
|
785
|
+
|
|
786
|
+
override fun spikePlay(): String = CompositionSpike.play()
|
|
787
|
+
|
|
788
|
+
override fun spikePause(): String = CompositionSpike.pause()
|
|
789
|
+
|
|
790
|
+
override fun spikeSeek(positionUs: Double): String =
|
|
791
|
+
CompositionSpike.seek(positionUs.toLong())
|
|
792
|
+
|
|
793
|
+
override fun spikeClaim(): String = CompositionSpike.claim()
|
|
794
|
+
|
|
795
|
+
override fun spikeState(): String = CompositionSpike.state()
|
|
796
|
+
|
|
797
|
+
override fun spikeDrain(fromSeq: Double, limit: Double): String =
|
|
798
|
+
CompositionSpike.drain(fromSeq.toLong(), limit.toInt())
|
|
799
|
+
|
|
800
|
+
override fun spikeRelease(): String = CompositionSpike.release()
|
|
801
|
+
|
|
802
|
+
override fun previewFramePipeline(): String {
|
|
803
|
+
val c = FrameClock.pipeline()
|
|
804
|
+
val d = VideoSurface.delivery()
|
|
805
|
+
return org.json.JSONObject()
|
|
806
|
+
.put("ok", true)
|
|
807
|
+
.put("offered", c[0])
|
|
808
|
+
.put("available", c[1])
|
|
809
|
+
.put("taken", c[2])
|
|
810
|
+
.put("orphaned", c[3])
|
|
811
|
+
.put("unpaired", c[4])
|
|
812
|
+
.put("stale", c[5])
|
|
813
|
+
.put("coalesced", c[6])
|
|
814
|
+
.put("generation", c[7])
|
|
815
|
+
// Entries to `take`, counted before the lock: a consumer that has
|
|
816
|
+
// stopped ticking and one blocked inside the latch are otherwise
|
|
817
|
+
// identical from the outside.
|
|
818
|
+
.put("takeCalls", c[8])
|
|
819
|
+
// Identity, separated from timing: a mislabelled frame and a late
|
|
820
|
+
// one produce the same offset and need opposite fixes.
|
|
821
|
+
.put("mismatched", FrameClock.mismatched)
|
|
822
|
+
.put("unverified", FrameClock.unverified)
|
|
823
|
+
// Why frames stopped arriving, when they have. A cleared listener,
|
|
824
|
+
// a quit delivery thread and a silent producer all look the same in
|
|
825
|
+
// `available` alone.
|
|
826
|
+
.put("watches", d[0])
|
|
827
|
+
.put("releases", d[1])
|
|
828
|
+
.put("threadQuits", d[2])
|
|
829
|
+
.put("deliveryAlive", d[3] == 1L)
|
|
830
|
+
.put("releaseLeadMs", FrameClock.lastReleaseLeadNs / 1_000_000)
|
|
831
|
+
.put("lastOfferedPtsMs", FrameClock.lastOfferedPtsUs / 1000)
|
|
832
|
+
.put("farFutureOffers", FrameClock.farFutureOffers)
|
|
833
|
+
.put("farPastOffers", FrameClock.farPastOffers)
|
|
834
|
+
.put("overflowed", FrameClock.overflowed)
|
|
835
|
+
.put("depth", FrameClock.depth())
|
|
836
|
+
.put("announced", d[4])
|
|
837
|
+
.put("rejectedAnnouncements", d[5])
|
|
838
|
+
.put("pendingDepth", d[6])
|
|
839
|
+
.put("updatesAttempted", d[7])
|
|
840
|
+
.put("updatesSucceeded", d[8])
|
|
841
|
+
.put("updatesFailed", d[9])
|
|
842
|
+
.put("updatesSkipped", d[10])
|
|
843
|
+
.toString()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* The host is stopping playback for a reason of its own.
|
|
849
|
+
*
|
|
850
|
+
* Backgrounding, a picker taking the foreground, an unmount. Any of them
|
|
851
|
+
* must override a resume the coordinator is holding, or playback would
|
|
852
|
+
* start again at a moment nobody asked for.
|
|
853
|
+
*/
|
|
854
|
+
override fun previewSyncStop(reason: String): String {
|
|
855
|
+
PreviewSync.onUserStop(reason)
|
|
856
|
+
PreviewAudioPlayer.current()?.pause()
|
|
857
|
+
return PreviewSync.counters().put("ok", true).toString()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** What the coordinator has done, for assertions. */
|
|
861
|
+
override fun previewSyncCounters(): String =
|
|
862
|
+
PreviewSync.counters().put("ok", true).toString()
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Compare the players and correct the mix past a tolerance.
|
|
867
|
+
*
|
|
868
|
+
* Native because both anchors are published on one clock here; asking from
|
|
869
|
+
* JavaScript would put a bridge hop inside the measurement.
|
|
870
|
+
*/
|
|
871
|
+
override fun previewDriftCheck(toleranceUs: Double): String =
|
|
872
|
+
PreviewTrace.driftCheck(toleranceUs.toLong()).toString()
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Gate-only: arm the paired trace and start sampling both players.
|
|
877
|
+
*
|
|
878
|
+
* Off by default even in a diagnostics build — sampling two players every
|
|
879
|
+
* few milliseconds is work a preview should not be doing for anyone who is
|
|
880
|
+
* not measuring it.
|
|
881
|
+
*/
|
|
882
|
+
override fun previewTraceArm(on: Boolean, intervalMs: Double): String {
|
|
883
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
884
|
+
PreviewTrace.arm(on)
|
|
885
|
+
if (on) PreviewTrace.startSampling(intervalMs.toLong()) else PreviewTrace.stopSampling()
|
|
886
|
+
return org.json.JSONObject().put("ok", true).put("armed", on).toString()
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Gate-only: a label the harness can find its own cases by. */
|
|
890
|
+
override fun previewTraceMark(label: String): String {
|
|
891
|
+
PreviewTrace.record("mark") { org.json.JSONObject().put("label", label) }
|
|
892
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Gate-only: drain events from a sequence number.
|
|
897
|
+
*
|
|
898
|
+
* Chunked by `limit` because a whole trace is far past what a single log
|
|
899
|
+
* line survives, and the caller pages rather than truncating.
|
|
900
|
+
*/
|
|
901
|
+
override fun previewTraceDrain(fromSeq: Double, limit: Double): String {
|
|
902
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
903
|
+
return PreviewTrace.drain(fromSeq.toLong(), limit.toInt()).toString()
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The mixed-audio player.
|
|
909
|
+
*
|
|
910
|
+
* A second, deliberately small player: one item, no surface, no playlist.
|
|
911
|
+
* Its position is the global timeline position, because the mix already
|
|
912
|
+
* covers the whole timeline with every clip speed compiled in.
|
|
913
|
+
*/
|
|
914
|
+
override fun previewAudioLoad(uri: String, epoch: Double): String {
|
|
915
|
+
// The bisect configuration that prepares a mix but creates no player
|
|
916
|
+
// stops here: `require` is what brings the second ExoPlayer into
|
|
917
|
+
// existence.
|
|
918
|
+
if (!PreviewConfig.loadPlayer) {
|
|
919
|
+
return org.json.JSONObject().put("ok", true).put("skipped", "loadPlayer").toString()
|
|
920
|
+
}
|
|
921
|
+
PreviewAudioPlayer.require(reactApplicationContext).load(uri, epoch.toLong())
|
|
922
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
override fun previewAudioPlay(epoch: Double): String {
|
|
926
|
+
PreviewAudioPlayer.current()?.play(epoch.toLong())
|
|
927
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
override fun previewAudioPause(): String {
|
|
931
|
+
PreviewAudioPlayer.current()?.pause()
|
|
932
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
override fun previewAudioSeek(positionUs: Double, epoch: Double): String {
|
|
936
|
+
PreviewAudioPlayer.current()?.seekTo(positionUs.toLong(), epoch.toLong())
|
|
937
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
override fun previewAudioSetRate(rate: Double): String {
|
|
941
|
+
PreviewAudioPlayer.current()?.setRate(rate.toFloat())
|
|
942
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
override fun previewAudioState(): String =
|
|
946
|
+
PreviewAudioPlayer.current()?.state()
|
|
947
|
+
?: org.json.JSONObject().put("ok", false).put("error", "no player").toString()
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Silence the video player's own audio while a mixed preview plays.
|
|
951
|
+
*
|
|
952
|
+
* The mix already contains the clips' audio, so both being audible plays
|
|
953
|
+
* the timeline twice.
|
|
954
|
+
*/
|
|
955
|
+
override fun previewMuteSourceAudio(muted: Boolean): String {
|
|
956
|
+
media3?.setSourceAudioMuted(muted)
|
|
957
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
override fun previewAudioRelease(): String {
|
|
961
|
+
PreviewAudioPlayer.releaseAll()
|
|
962
|
+
return org.json.JSONObject().put("ok", true).toString()
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
override fun previewMixKey(programJson: String, urisJson: String): String =
|
|
967
|
+
PreviewMix.keyFor(programJson, urisJson)
|
|
968
|
+
|
|
969
|
+
override fun previewMixRelease(key: String): String =
|
|
970
|
+
PreviewMix.release(reactApplicationContext, key)
|
|
971
|
+
|
|
972
|
+
override fun previewMixSweep(keepKeysJson: String): String =
|
|
973
|
+
try {
|
|
974
|
+
PreviewMix.sweep(reactApplicationContext, org.json.JSONArray(keepKeysJson))
|
|
975
|
+
} catch (e: Exception) {
|
|
976
|
+
// An unreadable keep list must never be read as "keep nothing".
|
|
977
|
+
org.json.JSONObject().put("ok", false).put("error", "unparseable keep list").toString()
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
override fun previewMixLedger(): String = PreviewMix.ledger(reactApplicationContext)
|
|
981
|
+
|
|
982
|
+
/** Copy a picked source into durable, app-owned storage. @see ManagedSources */
|
|
983
|
+
override fun ingestManagedSource(uri: String): String =
|
|
984
|
+
ManagedSources.ingest(reactApplicationContext, uri).toString()
|
|
985
|
+
|
|
986
|
+
override fun releaseManagedSource(token: String): String =
|
|
987
|
+
org.json.JSONObject()
|
|
988
|
+
.put("ok", true)
|
|
989
|
+
.put("removed", ManagedSources.release(reactApplicationContext, token))
|
|
990
|
+
.toString()
|
|
991
|
+
|
|
992
|
+
override fun sweepManagedSources(keepTokensJson: String): String =
|
|
993
|
+
try {
|
|
994
|
+
ManagedSources.sweep(reactApplicationContext, org.json.JSONArray(keepTokensJson)).toString()
|
|
995
|
+
} catch (e: Exception) {
|
|
996
|
+
// An unparseable keep list must never be read as "keep nothing".
|
|
997
|
+
org.json.JSONObject().put("ok", false).put("reason", "unparseable keep list").toString()
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
override fun managedSourceList(): String =
|
|
1001
|
+
ManagedSources.list(reactApplicationContext).toString()
|
|
1002
|
+
|
|
1003
|
+
/** Gate-only: sweep with an unparsed keep list, to prove refusal deletes nothing. */
|
|
1004
|
+
override fun managedSourceGateSweepRaw(rawKeep: String): String =
|
|
1005
|
+
ManagedSources.gateSweepRaw(reactApplicationContext, rawKeep).toString()
|
|
1006
|
+
|
|
1007
|
+
/** Gate-only: seed a file so the sweep's enumeration can be exercised. */
|
|
1008
|
+
override fun managedSourceGateSeed(name: String, bytes: Double): String =
|
|
1009
|
+
ManagedSources.gateSeed(reactApplicationContext, name, bytes.toInt()).toString()
|
|
1010
|
+
|
|
1011
|
+
override fun managedSourceLedger(): String =
|
|
1012
|
+
ManagedSources.ledger(reactApplicationContext).toString()
|
|
1013
|
+
|
|
1014
|
+
override fun probeAudioSource(uri: String): String =
|
|
1015
|
+
AudioProbe.probe(reactApplicationContext, uri).toString()
|
|
1016
|
+
|
|
1017
|
+
@androidx.media3.common.util.UnstableApi
|
|
1018
|
+
override fun exportProbeOutput(uri: String): String =
|
|
1019
|
+
GateDestination.probe(uri).toString()
|
|
1020
|
+
|
|
1021
|
+
/** Gate-only: hold inside the media copy, so a kill can land there. */
|
|
1022
|
+
@androidx.media3.common.util.UnstableApi
|
|
1023
|
+
override fun exportGateHoldMediaCopy(enabled: Boolean): String {
|
|
1024
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1025
|
+
MediaStoreSave.holdInCopy = enabled
|
|
1026
|
+
return org.json.JSONObject().put("ok", true).put("hold", enabled).toString()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Gate-only: wedge the cadence program after N frames. -1 is off. */
|
|
1030
|
+
@androidx.media3.common.util.UnstableApi
|
|
1031
|
+
override fun exportGateStallAfterFrames(frames: Double): String {
|
|
1032
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1033
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
1034
|
+
if (!running.diagnostics) {
|
|
1035
|
+
return org.json.JSONObject().put("ok", false)
|
|
1036
|
+
.put("error", "diagnostics are not enabled").toString()
|
|
1037
|
+
}
|
|
1038
|
+
running.stallAfterFrames = frames.toInt()
|
|
1039
|
+
return org.json.JSONObject().put("ok", true).put("stallAfterFrames", frames.toInt()).toString()
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Arm the gate-only controls.
|
|
1044
|
+
*
|
|
1045
|
+
* Every fault control, hold, provenance hash and test beat is inert until
|
|
1046
|
+
* this is called, including the ones a shard descriptor asks for. The
|
|
1047
|
+
* production exporter never calls it, so a shipped build cannot be talked
|
|
1048
|
+
* into injecting a failure by a malformed payload.
|
|
1049
|
+
*
|
|
1050
|
+
* Not reversible by accident either — passing `false` disarms and clears
|
|
1051
|
+
* every control, so a conformance run cannot leave one set.
|
|
1052
|
+
*/
|
|
1053
|
+
@androidx.media3.common.util.UnstableApi
|
|
1054
|
+
override fun exportGateEnableDiagnostics(enabled: Boolean): String {
|
|
1055
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1056
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
1057
|
+
running.armed = enabled
|
|
1058
|
+
if (!enabled) {
|
|
1059
|
+
running.provenance = false
|
|
1060
|
+
running.publishFault = ""
|
|
1061
|
+
running.stallAfterFrames = -1
|
|
1062
|
+
GateDestination.legacyPlaceholderPublish = false
|
|
1063
|
+
GateDestination.pretendNoAtomicNoReplace = false
|
|
1064
|
+
GateDestination.killDuringCopy = false
|
|
1065
|
+
GateDestination.betweenClaimAndRename = null
|
|
1066
|
+
}
|
|
1067
|
+
return org.json.JSONObject().put("diagnostics", enabled).toString()
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Gate-only: fail publication at "stage", "afterStage" or "raceCreate". */
|
|
1071
|
+
@androidx.media3.common.util.UnstableApi
|
|
1072
|
+
override fun exportGatePublishFault(mode: String): String {
|
|
1073
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1074
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
1075
|
+
if (!running.diagnostics) {
|
|
1076
|
+
return org.json.JSONObject().put("ok", false)
|
|
1077
|
+
.put("error", "diagnostics are not enabled").toString()
|
|
1078
|
+
}
|
|
1079
|
+
running.publishFault = mode
|
|
1080
|
+
// The publication-shape controls, which are not stages of the copy.
|
|
1081
|
+
GateDestination.legacyPlaceholderPublish = mode == "legacyClaim" || mode == "killBetween"
|
|
1082
|
+
GateDestination.pretendNoAtomicNoReplace = mode == "noAtomic"
|
|
1083
|
+
GateDestination.killDuringCopy = mode == "killDuringCopy"
|
|
1084
|
+
GateDestination.betweenClaimAndRename = if (mode == "killBetween") {
|
|
1085
|
+
{
|
|
1086
|
+
// A real process death, not a simulation of one: what is being
|
|
1087
|
+
// measured is the state the filesystem is left in, and only the
|
|
1088
|
+
// kernel can leave it.
|
|
1089
|
+
android.os.Process.killProcess(android.os.Process.myPid())
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
null
|
|
1093
|
+
}
|
|
1094
|
+
return org.json.JSONObject().put("ok", true).put("mode", mode).toString()
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Gate-only: does this filesystem have an atomic create-if-absent rename?
|
|
1099
|
+
*
|
|
1100
|
+
* Answered by doing it, in the directory the export would publish into. A
|
|
1101
|
+
* capability read from an API level or a mount table is a guess about the
|
|
1102
|
+
* filesystem the file is actually going to.
|
|
1103
|
+
*/
|
|
1104
|
+
@androidx.media3.common.util.UnstableApi
|
|
1105
|
+
override fun exportGateProbeNoReplace(directory: String): String {
|
|
1106
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1107
|
+
return GateDestination.probeNoReplace(java.io.File(directory)).toString()
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Gate-only: size and CRC32 of a file, to prove it did or did not change. */
|
|
1111
|
+
@androidx.media3.common.util.UnstableApi
|
|
1112
|
+
override fun exportGateFileDigest(path: String): String {
|
|
1113
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1114
|
+
val file = java.io.File(path)
|
|
1115
|
+
val result = org.json.JSONObject().put("exists", file.exists())
|
|
1116
|
+
if (!file.exists()) return result.toString()
|
|
1117
|
+
val crc = java.util.zip.CRC32()
|
|
1118
|
+
runCatching {
|
|
1119
|
+
java.io.FileInputStream(file).use { input ->
|
|
1120
|
+
val buffer = ByteArray(256 * 1024)
|
|
1121
|
+
while (true) {
|
|
1122
|
+
val read = input.read(buffer)
|
|
1123
|
+
if (read <= 0) break
|
|
1124
|
+
crc.update(buffer, 0, read)
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return result.put("bytes", file.length()).put("crc32", crc.value).toString()
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Save a finished export into the device's video collection.
|
|
1133
|
+
*
|
|
1134
|
+
* Off the bridge, because a copy is seconds of work and a cancel arriving
|
|
1135
|
+
* during it has to be deliverable. Settles once: the published `content:`
|
|
1136
|
+
* uri, or an error with the pending row already deleted.
|
|
1137
|
+
*/
|
|
1138
|
+
@androidx.media3.common.util.UnstableApi
|
|
1139
|
+
override fun mediaSaveAsync(
|
|
1140
|
+
jobId: String,
|
|
1141
|
+
sourceUri: String,
|
|
1142
|
+
promise: com.facebook.react.bridge.Promise,
|
|
1143
|
+
) {
|
|
1144
|
+
exportExecutor.execute {
|
|
1145
|
+
val result = try {
|
|
1146
|
+
MediaStoreSave.save(reactApplicationContext, jobId, sourceUri)
|
|
1147
|
+
} catch (e: Throwable) {
|
|
1148
|
+
org.json.JSONObject().put("ok", false)
|
|
1149
|
+
.put("error", "saving failed (" + e.javaClass.simpleName + ")").toString()
|
|
1150
|
+
}
|
|
1151
|
+
promise.resolve(result)
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Offer a saved video to the system share sheet.
|
|
1157
|
+
*
|
|
1158
|
+
* The durable `content:` uri only, with a read grant and nothing else.
|
|
1159
|
+
* Returns what the intent actually carried, so a gate can inspect it.
|
|
1160
|
+
*/
|
|
1161
|
+
@androidx.media3.common.util.UnstableApi
|
|
1162
|
+
override fun mediaShare(uri: String): String =
|
|
1163
|
+
MediaStoreSave.share(reactApplicationContext, uri)
|
|
1164
|
+
|
|
1165
|
+
/** Ask a running save to stop. Idempotent; a no-op for an unknown job. */
|
|
1166
|
+
@androidx.media3.common.util.UnstableApi
|
|
1167
|
+
override fun mediaSaveCancel(jobId: String): String =
|
|
1168
|
+
org.json.JSONObject().put("ok", true)
|
|
1169
|
+
.put("stopped", MediaStoreSave.cancel(jobId)).toString()
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Delete pending rows a killed process left behind.
|
|
1173
|
+
*
|
|
1174
|
+
* Only this app's, only still-pending, and only under its own folder. A
|
|
1175
|
+
* published item is never a candidate.
|
|
1176
|
+
*/
|
|
1177
|
+
@androidx.media3.common.util.UnstableApi
|
|
1178
|
+
override fun mediaSaveSweepPending(): String =
|
|
1179
|
+
MediaStoreSave.sweepPending(reactApplicationContext)
|
|
1180
|
+
|
|
1181
|
+
/** Confirm a saved item still opens. */
|
|
1182
|
+
@androidx.media3.common.util.UnstableApi
|
|
1183
|
+
override fun mediaSaveReadable(uri: String): String =
|
|
1184
|
+
MediaStoreSave.readable(reactApplicationContext, uri)
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Release one temporary output this module produced.
|
|
1188
|
+
*
|
|
1189
|
+
* Not a sweep. Idempotent, and refuses any path it did not create, so a
|
|
1190
|
+
* caller cannot use it to delete something of theirs.
|
|
1191
|
+
*/
|
|
1192
|
+
@androidx.media3.common.util.UnstableApi
|
|
1193
|
+
override fun exportReleaseTemporaryOutput(uri: String): String {
|
|
1194
|
+
val running = gate
|
|
1195
|
+
val released = running?.releaseTemporaryOutput(uri) ?: false
|
|
1196
|
+
return org.json.JSONObject().put("released", released).toString()
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Remove staging orphans a killed process left in a destination directory.
|
|
1201
|
+
*
|
|
1202
|
+
* Positively identified only: the `.pictor-staging-` prefix and the `.tmp`
|
|
1203
|
+
* suffix, in a directory the caller names. Never a wildcard over user files
|
|
1204
|
+
* that happen to look similar.
|
|
1205
|
+
*/
|
|
1206
|
+
@androidx.media3.common.util.UnstableApi
|
|
1207
|
+
override fun exportSweepStagingOrphans(directory: String): String {
|
|
1208
|
+
val target = GateDestination.Target(java.io.File(directory, "unused").absolutePath, false)
|
|
1209
|
+
return org.json.JSONObject()
|
|
1210
|
+
.put("swept", GateDestination.sweepStaging(target))
|
|
1211
|
+
.toString()
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Gate-only: which temporaries are still registered, by name only. */
|
|
1215
|
+
@androidx.media3.common.util.UnstableApi
|
|
1216
|
+
override fun exportGateLiveTemporaries(): String {
|
|
1217
|
+
val names = org.json.JSONArray()
|
|
1218
|
+
gate?.liveTemporaryNames()?.forEach { names.put(it) }
|
|
1219
|
+
return names.toString()
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Gate-only: remove a file, so a case can start from a known state. */
|
|
1223
|
+
@androidx.media3.common.util.UnstableApi
|
|
1224
|
+
override fun exportGateDeleteFile(path: String): String {
|
|
1225
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1226
|
+
val file = java.io.File(path)
|
|
1227
|
+
val existed = file.exists()
|
|
1228
|
+
val gone = runCatching { file.delete() }.getOrDefault(false)
|
|
1229
|
+
return org.json.JSONObject().put("existed", existed).put("gone", gone).toString()
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Gate-only: a writable directory that is not the gallery. */
|
|
1233
|
+
@androidx.media3.common.util.UnstableApi
|
|
1234
|
+
override fun exportGateExternalFilesDir(): String {
|
|
1235
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1236
|
+
return org.json.JSONObject()
|
|
1237
|
+
.put("path", reactApplicationContext.getExternalFilesDir(null)?.absolutePath ?: "")
|
|
1238
|
+
.put(
|
|
1239
|
+
"staging",
|
|
1240
|
+
(reactApplicationContext.getExternalFilesDir(null)?.listFiles { f ->
|
|
1241
|
+
f.name.startsWith(".pictor-staging-")
|
|
1242
|
+
}?.size) ?: -1,
|
|
1243
|
+
)
|
|
1244
|
+
.toString()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Gate-only: the current heartbeat epoch. */
|
|
1248
|
+
@androidx.media3.common.util.UnstableApi
|
|
1249
|
+
override fun exportGateBeatEpoch(): String {
|
|
1250
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1251
|
+
return org.json.JSONObject().put("epoch", gate?.currentBeatEpoch() ?: -1L).toString()
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** Gate-only: offer a beat for `epoch`; reports whether it was accepted. */
|
|
1255
|
+
@androidx.media3.common.util.UnstableApi
|
|
1256
|
+
override fun exportGateTestBeat(source: String, epoch: Double): String {
|
|
1257
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1258
|
+
return org.json.JSONObject()
|
|
1259
|
+
.put("accepted", gate?.testBeat(source, epoch.toLong()) ?: false)
|
|
1260
|
+
.toString()
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Gate-only: enable sample hashing so conformance can be checked. */
|
|
1264
|
+
@androidx.media3.common.util.UnstableApi
|
|
1265
|
+
override fun exportGateProvenance(enabled: Boolean): String {
|
|
1266
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1267
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
1268
|
+
if (!running.diagnostics) {
|
|
1269
|
+
return org.json.JSONObject().put("ok", false)
|
|
1270
|
+
.put("error", "diagnostics are not enabled").toString()
|
|
1271
|
+
}
|
|
1272
|
+
running.provenance = enabled
|
|
1273
|
+
return org.json.JSONObject().put("ok", true).put("provenance", enabled).toString()
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Heartbeat counts and the maximum gap per source.
|
|
1278
|
+
*
|
|
1279
|
+
* The gap is what the watchdog's threshold has to be derived from: the
|
|
1280
|
+
* longest silence real work produces, not how long the platform takes to
|
|
1281
|
+
* notice a wedge.
|
|
1282
|
+
*/
|
|
1283
|
+
@androidx.media3.common.util.UnstableApi
|
|
1284
|
+
override fun exportGateHeartbeats(): String =
|
|
1285
|
+
gate?.heartbeatJson() ?: org.json.JSONObject().put("sources", org.json.JSONObject()).toString()
|
|
1286
|
+
|
|
1287
|
+
/** Gate-only: run and resource accounting, by domain. */
|
|
1288
|
+
@androidx.media3.common.util.UnstableApi
|
|
1289
|
+
override fun exportGateAccounting(): String =
|
|
1290
|
+
gate?.accountingJson() ?: org.json.JSONObject().put("runs", org.json.JSONObject()).toString()
|
|
1291
|
+
|
|
1292
|
+
/** Stop `jobId` if it is the one running. Idempotent. */
|
|
1293
|
+
@androidx.media3.common.util.UnstableApi
|
|
1294
|
+
override fun exportCancelJob(jobId: String): String {
|
|
1295
|
+
val running = gate
|
|
1296
|
+
val stopped = running?.cancelJob(jobId) ?: false
|
|
1297
|
+
return org.json.JSONObject().put("ok", true).put("stopped", stopped).toString()
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Run a compiled project. The production path, and not a gate.
|
|
1302
|
+
*
|
|
1303
|
+
* Split out because the production async entry used to call
|
|
1304
|
+
* `exportGateRunProject`, which meant guarding the gate surface killed real
|
|
1305
|
+
* exports in a release build: every one failed with "diagnostics unavailable
|
|
1306
|
+
* in this build". Caught by the boundary gate on device, which is the reason
|
|
1307
|
+
* that gate exists — "the exporter calls no gate method" was true of the
|
|
1308
|
+
* TypeScript and false of the native entry point it went through.
|
|
1309
|
+
*
|
|
1310
|
+
* The gate wrapper below now adds only its refusal.
|
|
1311
|
+
*/
|
|
1312
|
+
@androidx.media3.common.util.UnstableApi
|
|
1313
|
+
private fun runProjectInternal(
|
|
1314
|
+
shardsJson: String,
|
|
1315
|
+
programJson: String,
|
|
1316
|
+
overlaysJson: String,
|
|
1317
|
+
mixProgramJson: String,
|
|
1318
|
+
mixUrisJson: String,
|
|
1319
|
+
timeoutSeconds: Double,
|
|
1320
|
+
destinationJson: String,
|
|
1321
|
+
): String {
|
|
1322
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
1323
|
+
val shards = try {
|
|
1324
|
+
val array = org.json.JSONArray(shardsJson)
|
|
1325
|
+
(0 until array.length()).map {
|
|
1326
|
+
val o = array.getJSONObject(it)
|
|
1327
|
+
ExportGate.Shard(
|
|
1328
|
+
o.getString("uri"), o.optLong("startMs", 0), o.optLong("endMs", 0),
|
|
1329
|
+
o.optDouble("speed", 1.0).toFloat(), o.optLong("timelineOffsetUs", 0),
|
|
1330
|
+
o.optBoolean("forceAudioTrack", false), o.optInt("frameDropFps", 0),
|
|
1331
|
+
o.optLong("encoderRepeatUs", 0),
|
|
1332
|
+
o.optJSONArray("emitsPerInput")?.let { a -> IntArray(a.length()) { i -> a.getInt(i) } },
|
|
1333
|
+
o.optJSONArray("outputPtsUs")?.let { a -> LongArray(a.length()) { i -> a.getLong(i) } },
|
|
1334
|
+
/*
|
|
1335
|
+
Read, and then ignored unless the build has diagnostics.
|
|
1336
|
+
|
|
1337
|
+
Parsing them unconditionally keeps one payload shape; what
|
|
1338
|
+
makes a release safe is that `ExportGate` will not act on
|
|
1339
|
+
them, not that they were absent from the JSON.
|
|
1340
|
+
*/
|
|
1341
|
+
o.optInt("cadenceFailAfterEmits", -1),
|
|
1342
|
+
o.optLong("spanUs", 0),
|
|
1343
|
+
o.optBoolean("removeAudio", false), o.optString("speedMode", "preserve"),
|
|
1344
|
+
o.optInt("overlayFailAfterDraws", -1), o.optBoolean("failConcat", false),
|
|
1345
|
+
o.optInt("cadenceStallAfterEmits", -1),
|
|
1346
|
+
)
|
|
1347
|
+
}
|
|
1348
|
+
} catch (e: Exception) {
|
|
1349
|
+
return org.json.JSONObject().put("ok", false).put("error", "shards: " + e.message).toString()
|
|
1350
|
+
}
|
|
1351
|
+
val outcome = runCatching {
|
|
1352
|
+
running.runProject(
|
|
1353
|
+
shards, programJson, overlaysJson, mixProgramJson, mixUrisJson, timeoutSeconds.toLong(),
|
|
1354
|
+
destinationJson,
|
|
1355
|
+
)
|
|
1356
|
+
}.getOrElse {
|
|
1357
|
+
return org.json.JSONObject().put("ok", false)
|
|
1358
|
+
.put("error", "export threw: " + it.message).toString()
|
|
1359
|
+
}
|
|
1360
|
+
return report(outcome, running)
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** The blocking gate entry. Same work, plus the refusal. */
|
|
1364
|
+
@androidx.media3.common.util.UnstableApi
|
|
1365
|
+
override fun exportGateRunProject(
|
|
1366
|
+
shardsJson: String,
|
|
1367
|
+
programJson: String,
|
|
1368
|
+
overlaysJson: String,
|
|
1369
|
+
mixProgramJson: String,
|
|
1370
|
+
mixUrisJson: String,
|
|
1371
|
+
timeoutSeconds: Double,
|
|
1372
|
+
destinationJson: String,
|
|
1373
|
+
): String {
|
|
1374
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1375
|
+
return runProjectInternal(
|
|
1376
|
+
shardsJson, programJson, overlaysJson, mixProgramJson, mixUrisJson,
|
|
1377
|
+
timeoutSeconds, destinationJson,
|
|
1378
|
+
)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
override fun exportGateMixAudio(programJson: String, urisJson: String): String {
|
|
1382
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1383
|
+
val file = java.io.File.createTempFile(
|
|
1384
|
+
"pictor-gate-mix-", ".m4a", reactApplicationContext.cacheDir,
|
|
1385
|
+
)
|
|
1386
|
+
val result = AudioMixer.mix(reactApplicationContext, programJson, urisJson, file)
|
|
1387
|
+
if (!org.json.JSONObject(result).optBoolean("ok", false)) {
|
|
1388
|
+
runCatching { file.delete() }
|
|
1389
|
+
}
|
|
1390
|
+
return result
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
override fun exportGateDecodeProbe(
|
|
1394
|
+
uri: String,
|
|
1395
|
+
fromUs: Double,
|
|
1396
|
+
toUs: Double,
|
|
1397
|
+
speed: Double,
|
|
1398
|
+
): String = AudioMixer.probeDecode(
|
|
1399
|
+
reactApplicationContext, uri, fromUs.toLong(), toUs.toLong(), speed.toFloat(),
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
override fun exportGatePrerollProbe(
|
|
1403
|
+
uri: String,
|
|
1404
|
+
fromUs: Double,
|
|
1405
|
+
toUs: Double,
|
|
1406
|
+
warmupUs: Double,
|
|
1407
|
+
): String = AudioMixer.probePreroll(
|
|
1408
|
+
reactApplicationContext, uri, fromUs.toLong(), toUs.toLong(), warmupUs.toLong(),
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Copy a gate output somewhere a person can play it.
|
|
1413
|
+
*
|
|
1414
|
+
* The human gates need to hear and watch the file, and the adapter's own
|
|
1415
|
+
* temporaries live in app-private cache where nothing else can open them.
|
|
1416
|
+
* This copies, so the cleanup accounting over the originals stays true.
|
|
1417
|
+
*/
|
|
1418
|
+
override fun exportGateTrajectory(uri: String, spec: String): String {
|
|
1419
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1420
|
+
return GateAudio.trajectory(reactApplicationContext, uri, spec)
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
override fun exportGateStage(uri: String, name: String): String {
|
|
1424
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1425
|
+
val out = org.json.JSONObject()
|
|
1426
|
+
return try {
|
|
1427
|
+
val dir = reactApplicationContext.getExternalFilesDir(null)
|
|
1428
|
+
?: return out.put("ok", false).put("error", "no external files dir").toString()
|
|
1429
|
+
val target = java.io.File(dir, name)
|
|
1430
|
+
java.io.File(uri).inputStream().use { input ->
|
|
1431
|
+
target.outputStream().use { output -> input.copyTo(output) }
|
|
1432
|
+
}
|
|
1433
|
+
out.put("ok", true).put("path", target.absolutePath).put("bytes", target.length()).toString()
|
|
1434
|
+
} catch (e: Exception) {
|
|
1435
|
+
out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
override fun exportGatePrimingProbe(
|
|
1440
|
+
rate: Double,
|
|
1441
|
+
channels: Double,
|
|
1442
|
+
bitrate: Double,
|
|
1443
|
+
at: Double,
|
|
1444
|
+
): String = AacPriming.probe(rate.toInt(), channels.toInt(), bitrate.toInt(), at.toInt())
|
|
1445
|
+
|
|
1446
|
+
override fun exportGateStretchProbe(
|
|
1447
|
+
rate: Double,
|
|
1448
|
+
channels: Double,
|
|
1449
|
+
frames: Double,
|
|
1450
|
+
speed: Double,
|
|
1451
|
+
): String = AudioMixer.probeStretch(rate.toInt(), channels.toInt(), frames.toInt(), speed.toFloat())
|
|
1452
|
+
|
|
1453
|
+
override fun exportGateStageResource(base64: String, width: Double, height: Double): String {
|
|
1454
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1455
|
+
val out = org.json.JSONObject()
|
|
1456
|
+
try {
|
|
1457
|
+
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
|
1458
|
+
val file = java.io.File.createTempFile(
|
|
1459
|
+
"pictor-gate-overlay-", ".rgba", reactApplicationContext.cacheDir,
|
|
1460
|
+
)
|
|
1461
|
+
file.writeBytes(bytes)
|
|
1462
|
+
val crc = java.util.zip.CRC32()
|
|
1463
|
+
crc.update(bytes)
|
|
1464
|
+
out.put("ok", true).put("file", file.absolutePath)
|
|
1465
|
+
.put("byteLength", bytes.size).put("checksum", crc.value)
|
|
1466
|
+
} catch (e: Exception) {
|
|
1467
|
+
out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
|
|
1468
|
+
}
|
|
1469
|
+
return out.toString()
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
override fun exportGateAudio(uri: String, windowsJson: String): String {
|
|
1473
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1474
|
+
return GateAudio.analyse(reactApplicationContext, uri, windowsJson)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
override fun exportGateProbe(uri: String, probesJson: String): String {
|
|
1478
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1479
|
+
return GateFrames.probe(reactApplicationContext, uri, probesJson)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
override fun exportGateTracks(uri: String): String {
|
|
1483
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1484
|
+
return GateFrames.tracks(reactApplicationContext, uri)
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
override fun exportGateSample(uri: String, timestampsUs: ReadableArray): String {
|
|
1488
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1489
|
+
val times = LongArray(timestampsUs.size()) { timestampsUs.getDouble(it).toLong() }
|
|
1490
|
+
return GateFrames.sample(reactApplicationContext, uri, times)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
override fun exportGateCompare(
|
|
1494
|
+
leftUri: String,
|
|
1495
|
+
rightUri: String,
|
|
1496
|
+
timestampsUs: ReadableArray,
|
|
1497
|
+
): String {
|
|
1498
|
+
if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
|
|
1499
|
+
val times = LongArray(timestampsUs.size()) { timestampsUs.getDouble(it).toLong() }
|
|
1500
|
+
return GateFrames.compare(reactApplicationContext, leftUri, rightUri, times)
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
override fun media3Play() { media3?.play() }
|
|
1504
|
+
|
|
1505
|
+
override fun media3Pause() { media3?.pause() }
|
|
1506
|
+
|
|
1507
|
+
override fun media3Seek(positionUs: Double) { media3?.seekTo(positionUs.toLong()) }
|
|
1508
|
+
|
|
1509
|
+
override fun media3SetRate(rate: Double) { media3?.setRate(rate.toFloat()) }
|
|
1510
|
+
|
|
1511
|
+
/** Ask the current player to try its source again after a failure. */
|
|
1512
|
+
override fun media3Retry() { media3?.retry() }
|
|
1513
|
+
|
|
1514
|
+
override fun media3Release() {
|
|
1515
|
+
media3?.release()
|
|
1516
|
+
media3 = null
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// ---- Thumbnails ------------------------------------------------------
|
|
1520
|
+
|
|
1521
|
+
override fun thumbnailRequest(uri: String, sourceTimeUs: Double, widthPx: Double): Double =
|
|
1522
|
+
ThumbnailDecoder.request(
|
|
1523
|
+
uri,
|
|
1524
|
+
sourceTimeUs.toLong(),
|
|
1525
|
+
widthPx.toInt(),
|
|
1526
|
+
reactApplicationContext,
|
|
1527
|
+
).toDouble()
|
|
1528
|
+
|
|
1529
|
+
override fun thumbnailCancel(token: Double) { ThumbnailDecoder.cancel(token.toLong()) }
|
|
1530
|
+
|
|
1531
|
+
/** Base64 JPEG, or null while it is still decoding. Consumes the result. */
|
|
1532
|
+
override fun thumbnailTake(token: Double): String? = ThumbnailDecoder.take(token.toLong())
|
|
1533
|
+
|
|
1534
|
+
override fun thumbnailError(token: Double): String? =
|
|
1535
|
+
ThumbnailDecoder.takeError(token.toLong())
|
|
1536
|
+
|
|
1537
|
+
override fun thumbnailRunning(token: Double): Boolean =
|
|
1538
|
+
ThumbnailDecoder.isRunning(token.toLong())
|
|
1539
|
+
|
|
1540
|
+
override fun thumbnailReleaseSource(uri: String) { ThumbnailDecoder.releaseSource(uri) }
|
|
1541
|
+
|
|
1542
|
+
override fun thumbnailReleaseAll() { ThumbnailDecoder.releaseAll() }
|
|
1543
|
+
|
|
1544
|
+
override fun thumbnailCounters(): WritableMap {
|
|
1545
|
+
val counters = ThumbnailDecoder.counters()
|
|
1546
|
+
return Arguments.createMap().apply {
|
|
1547
|
+
putDouble("decoded", counters[0].toDouble())
|
|
1548
|
+
putDouble("cancelled", counters[1].toDouble())
|
|
1549
|
+
putDouble("failed", counters[2].toDouble())
|
|
1550
|
+
putDouble("running", counters[3].toDouble())
|
|
1551
|
+
putDouble("retrievers", counters[4].toDouble())
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
override fun media3State(): WritableMap {
|
|
1556
|
+
val out = Arguments.createMap()
|
|
1557
|
+
val snapshot = media3?.state() ?: mapOf("released" to true)
|
|
1558
|
+
for ((key, value) in snapshot) {
|
|
1559
|
+
when (value) {
|
|
1560
|
+
is Boolean -> out.putBoolean(key, value)
|
|
1561
|
+
is Long -> out.putDouble(key, value.toDouble())
|
|
1562
|
+
is Int -> out.putInt(key, value)
|
|
1563
|
+
is Float -> out.putDouble(key, value.toDouble())
|
|
1564
|
+
// Double had no branch, so every one of them fell to the
|
|
1565
|
+
// `else` and arrived in JS as a *string*. `"1.00"` reads as
|
|
1566
|
+
// present, spreads over a numeric default, and kills the app on
|
|
1567
|
+
// the first `.toFixed` — a silent stringification is worse than
|
|
1568
|
+
// a dropped field, because the value looks fine in a log.
|
|
1569
|
+
is Double -> out.putDouble(key, value)
|
|
1570
|
+
is Number -> out.putDouble(key, value.toDouble())
|
|
1571
|
+
is String -> out.putString(key, value)
|
|
1572
|
+
// Anything else is a type this boundary does not know how to
|
|
1573
|
+
// carry. Stringifying it silently is what hid the Doubles, so
|
|
1574
|
+
// it is named instead: a consumer sees the type it got rather
|
|
1575
|
+
// than a plausible-looking string.
|
|
1576
|
+
else -> out.putString(key, "<unconvertible ${value.javaClass.simpleName}>")
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
out.putString("error", lastMedia3Error)
|
|
1580
|
+
return out
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
private var lastMedia3Error: String = ""
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Release everything when the React context goes away.
|
|
1588
|
+
*
|
|
1589
|
+
* A reload or a host teardown does not give JS a chance to release, and a
|
|
1590
|
+
* player outliving its context holds a codec and a surface the next one
|
|
1591
|
+
* needs.
|
|
1592
|
+
*/
|
|
1593
|
+
override fun invalidate() {
|
|
1594
|
+
ThumbnailDecoder.releaseAll()
|
|
1595
|
+
media3?.release()
|
|
1596
|
+
media3 = null
|
|
1597
|
+
super.invalidate()
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
companion object {
|
|
1601
|
+
const val NAME = "PictorVideo"
|
|
1602
|
+
|
|
1603
|
+
init { System.loadLibrary("pictor_video") }
|
|
1604
|
+
|
|
1605
|
+
/** Implemented in `cpp/pictor_videosurface.cpp`. */
|
|
1606
|
+
@JvmStatic external fun nativeInstallVideoSurface(runtimePtr: Long): Boolean
|
|
1607
|
+
@JvmStatic external fun nativeInstallOverlayStaging(
|
|
1608
|
+
runtimePtr: Long,
|
|
1609
|
+
cacheDir: String,
|
|
1610
|
+
): Boolean
|
|
1611
|
+
}
|
|
1612
|
+
}
|