@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,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
+ }