@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,396 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import java.security.MessageDigest
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.atomic.AtomicLong
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+
11
+ /**
12
+ * The audio a preview plays, prepared once and reused.
13
+ *
14
+ * ## Why a file, and why the same mixer
15
+ *
16
+ * Preview and export must agree about what a project sounds like, and the only
17
+ * way to guarantee that is for both to run the same code over the same plan.
18
+ * [AudioMixer] already sums spans, applies gains and ramps, stretches by clip
19
+ * speed and saturates once — so preview asks it for exactly what an export
20
+ * asks, and plays the result. Reimplementing any of that in the player, or in
21
+ * React, would be a second definition of the sound.
22
+ *
23
+ * The output is an app-private file rather than a stream because the mix is not
24
+ * realtime: it decodes every contributing source, re-times it and encodes once.
25
+ * See the latency table in the JS wrapper — a caller has to know this is
26
+ * preparation, not playback.
27
+ *
28
+ * ## Keyed by what the sound depends on, and nothing else
29
+ *
30
+ * The cache key is the digest of the compiled mix program, the resolved source
31
+ * URIs and the mixer's own configuration. That is precisely the set of things
32
+ * that can change the samples: selecting a clip, opening a sheet or scrolling
33
+ * the timeline change none of them, so they cannot invalidate a mix. A trim, a
34
+ * volume, a fade, a speed, a mute or a different source all change the program,
35
+ * so they all do.
36
+ *
37
+ * ## One job, and the newest wins
38
+ *
39
+ * A newer request cancels the one in flight. The old job unwinds through
40
+ * [AudioMixer.Cancelled], deletes its partial output and reports `superseded` —
41
+ * which the caller must not publish. Generation numbers rather than flags: a
42
+ * job compares the generation it was started with against the current one, so a
43
+ * completion that arrives late can tell that it is late.
44
+ */
45
+ object PreviewMix {
46
+
47
+ private const val DIR = "pictor-preview"
48
+ private const val SUFFIX = ".m4a"
49
+ private const val PART_SUFFIX = ".part"
50
+
51
+ /**
52
+ * The mixer configuration that reaches the key.
53
+ *
54
+ * Bumped when anything about how the samples are produced changes, so a
55
+ * cached file from an older rule is never played. It is not a schema
56
+ * version: the program carries its own.
57
+ *
58
+ * `v3` is the priming shift. `v1` never corrected the delay at all and plays
59
+ * about 43 ms late; `v2` corrected it by dropping access units and lost the
60
+ * first 43 ms of any sound starting at zero. Both are silently wrong rather
61
+ * than merely slow, which is the worse thing for a cache to be.
62
+ */
63
+ private const val MIXER_CONFIG = "48000/2/sum-then-saturate/v3-primingshifted"
64
+
65
+ private val executor = Executors.newSingleThreadExecutor { r ->
66
+ Thread(r, "pictor-preview-mix").apply { isDaemon = true }
67
+ }
68
+
69
+ /** The generation a job must still match to be allowed to publish. */
70
+ private val generation = AtomicLong(0)
71
+
72
+ /** Bumped by [cancel] and by every new request. */
73
+ @Volatile private var currentJob: String? = null
74
+
75
+ /** Preparations started and finished, so an abandoned one is visible. */
76
+ @Volatile private var jobsStarted = 0L
77
+ @Volatile private var jobsFinished = 0L
78
+
79
+ fun jobCounters(): JSONObject =
80
+ JSONObject()
81
+ .put("started", jobsStarted)
82
+ .put("finished", jobsFinished)
83
+ .put("live", jobsStarted - jobsFinished)
84
+ .put("current", currentJob ?: "")
85
+
86
+ private fun dir(context: Context): File {
87
+ // `filesDir`: a cached mix that vanished mid-session would silently
88
+ // become "Preparing audio" again with nothing to explain it.
89
+ val d = File(context.filesDir, DIR)
90
+ if (!d.exists()) d.mkdirs()
91
+ return d
92
+ }
93
+
94
+ /**
95
+ * The name a given sound has.
96
+ *
97
+ * Content-addressed like a managed source, and for one of the same reasons:
98
+ * a partial file can never wear the name of a complete one, because the
99
+ * name is decided before the work and the file only takes it after a
100
+ * successful rename.
101
+ */
102
+ fun keyFor(programJson: String, urisJson: String): String {
103
+ val digest = MessageDigest.getInstance("SHA-256")
104
+ digest.update(MIXER_CONFIG.toByteArray())
105
+ digest.update(programJson.toByteArray())
106
+ digest.update(urisJson.toByteArray())
107
+ return digest.digest().joinToString("") { "%02x".format(it) }
108
+ }
109
+
110
+ /** Where a prepared mix lives, if it is there. */
111
+ fun cached(context: Context, key: String): File? {
112
+ val file = File(dir(context), key + SUFFIX)
113
+ return if (file.exists() && file.length() > 0) file else null
114
+ }
115
+
116
+ /**
117
+ * Prepare the mix for this program, or return the one already prepared.
118
+ *
119
+ * Blocking: the caller runs it on [executor] via the module. Returns
120
+ * `{ok, uri, key, cached, durationUs, byteLength}`, or `{ok:false, ...}`
121
+ * with `superseded` or `cancelled` set when it stopped rather than failed.
122
+ */
123
+ fun prepare(context: Context, jobId: String, programJson: String, urisJson: String): String =
124
+ try {
125
+ prepareInner(context, jobId, programJson, urisJson)
126
+ } finally {
127
+ /*
128
+ Every exit, not just the cancelled one.
129
+
130
+ The first version of this counter only closed a job in `cancel`,
131
+ so a run of sixteen rebuilds reported sixteen live jobs and no
132
+ finished ones — a measurement artefact that reads exactly like a
133
+ leak. `finally` is what makes "started" and "finished" answer the
134
+ same question.
135
+ */
136
+ jobsFinished += 1
137
+ }
138
+
139
+ private fun prepareInner(
140
+ context: Context,
141
+ jobId: String,
142
+ programJson: String,
143
+ urisJson: String,
144
+ ): String {
145
+ val out = JSONObject()
146
+ val key = keyFor(programJson, urisJson)
147
+ val mine = generation.incrementAndGet()
148
+ currentJob = jobId
149
+ jobsStarted += 1
150
+ PreviewTrace.record("mix.start") {
151
+ JSONObject().put("job", jobId).put("key", key.take(12)).put("gen", mine)
152
+ }
153
+
154
+ cached(context, key)?.let { file ->
155
+ // Already prepared. Still checked against the generation: a request
156
+ // superseded before it got here must not publish, even instantly.
157
+ if (generation.get() != mine) {
158
+ return out.put("ok", false).put("superseded", true).put("key", key).toString()
159
+ }
160
+ PreviewTrace.record("mix.cached") {
161
+ JSONObject().put("key", key.take(12)).put("gen", mine)
162
+ }
163
+ return out
164
+ .put("ok", true)
165
+ .put("uri", android.net.Uri.fromFile(file).toString())
166
+ .put("key", key)
167
+ .put("cached", true)
168
+ .put("byteLength", file.length())
169
+ .toString()
170
+ }
171
+
172
+ val part = File(dir(context), "$key$SUFFIX$PART_SUFFIX")
173
+ CodecLedger.currentJob = jobId
174
+ try {
175
+ val result = JSONObject(
176
+ AudioMixer.mix(
177
+ context,
178
+ programJson,
179
+ urisJson,
180
+ part,
181
+ // Cancelled the moment a newer request arrives. Checked in
182
+ // every loop that can run for seconds, so superseding does
183
+ // not wait out a decode and an encode.
184
+ keepGoing = { generation.get() == mine },
185
+ ),
186
+ )
187
+ if (!result.optBoolean("ok", false)) {
188
+ runCatching { part.delete() }
189
+ return out
190
+ .put("ok", false)
191
+ .put("key", key)
192
+ .put("error", result.optString("error", "mix failed"))
193
+ .toString()
194
+ }
195
+ } catch (e: AudioMixer.Cancelled) {
196
+ PreviewTrace.record("mix.superseded") {
197
+ JSONObject().put("key", key.take(12)).put("gen", mine).put("at", "mix")
198
+ }
199
+ // Superseded or cancelled. The partial goes with it: a half-encoded
200
+ // file under a name nothing will ask for is storage nobody can
201
+ // account for.
202
+ runCatching { part.delete() }
203
+ return out.put("ok", false).put("superseded", true).put("key", key).toString()
204
+ } catch (e: Throwable) {
205
+ runCatching { part.delete() }
206
+ return out
207
+ .put("ok", false)
208
+ .put("key", key)
209
+ .put("error", e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
210
+ .toString()
211
+ }
212
+
213
+ if (generation.get() != mine) {
214
+ // Finished, but the answer is to a question nobody is asking any
215
+ // more. Never published, and never left behind.
216
+ PreviewTrace.record("mix.superseded") {
217
+ JSONObject().put("key", key.take(12)).put("gen", mine).put("at", "afterMix")
218
+ }
219
+ runCatching { part.delete() }
220
+ return out.put("ok", false).put("superseded", true).put("key", key).toString()
221
+ }
222
+
223
+ /*
224
+ Preparation gives its codecs back before anything adopts the file.
225
+
226
+ Every codec a job creates is its own to release, and a hardware
227
+ instance that outlives the job is one the video decoder cannot have.
228
+ Reported rather than enforced with a wait: a publication that blocked
229
+ on a stuck codec would turn a resource leak into a hang, and the
230
+ count is what identifies the defect either way.
231
+ */
232
+ CodecLedger.trimCompleted()
233
+ val codecs = CodecLedger.snapshot()
234
+ val liveAfterMix = codecs.optInt("live", -1)
235
+ val profile = AudioMixer.profile.json()
236
+
237
+ /*
238
+ The start moves before anything adopts the file.
239
+
240
+ `AudioMixer.encode` is left exactly as it was — its output is the
241
+ frozen baseline the encoder rewrite was checked against, and moving
242
+ the trim inside it would invalidate that and change what an export
243
+ mixes from. So the raw mix is written, trimmed once into the name the
244
+ cache will use, and the raw copy removed. Corrected exactly once, at
245
+ the point of publication, by the same code the export mux uses.
246
+ */
247
+ val trimAt = System.nanoTime()
248
+ val target = File(dir(context), key + SUFFIX)
249
+ /*
250
+ The compiled end reaches the publication, so preview follows the same
251
+ tail policy as an export.
252
+
253
+ Without it the mix ran 643 frames — 13.4 ms — past the timeline: the
254
+ encoder pads its last frame and nothing was trimming it. Marker
255
+ alignment says nothing about the end, which is why the duration is
256
+ checked separately from the offsets.
257
+ */
258
+ val programEndUs = try {
259
+ org.json.JSONObject(programJson).optLong("durationUs", 0)
260
+ } catch (e: Exception) {
261
+ 0L
262
+ }
263
+ val trimmed = PrimingTrim.shiftAudioFile(
264
+ context, part, target, programEndUs,
265
+ // Superseding still wins during the trim: it is short, but a rapid
266
+ // edit must not have to wait out a remux either.
267
+ keepGoing = { generation.get() == mine },
268
+ )
269
+ val trimMs = (System.nanoTime() - trimAt) / 1_000_000
270
+ runCatching { part.delete() }
271
+ if (!trimmed.optBoolean("ok", false)) {
272
+ // Both outputs go: the raw mix above, and whatever the trim managed
273
+ // to write under the published name.
274
+ runCatching { target.delete() }
275
+ if (trimmed.optBoolean("cancelled", false)) {
276
+ return out.put("ok", false).put("superseded", true).put("key", key).toString()
277
+ }
278
+ return out
279
+ .put("ok", false)
280
+ .put("key", key)
281
+ .put("error", trimmed.optString("error", "the mix could not be corrected"))
282
+ .toString()
283
+ }
284
+ if (generation.get() != mine) {
285
+ runCatching { target.delete() }
286
+ return out.put("ok", false).put("superseded", true).put("key", key).toString()
287
+ }
288
+
289
+ PreviewTrace.record("mix.published") {
290
+ JSONObject().put("key", key.take(12)).put("gen", mine)
291
+ .put("bytes", target.length()).put("trimMs", trimMs)
292
+ }
293
+ return out
294
+ .put("ok", true)
295
+ .put("uri", android.net.Uri.fromFile(target).toString())
296
+ .put("key", key)
297
+ .put("cached", false)
298
+ .put("byteLength", target.length())
299
+ // Where the time went. A multi-second preparation is a product
300
+ // problem, and the first thing anyone will do about it is guess.
301
+ .put("profile", profile)
302
+ .put("trimMs", trimMs)
303
+ .put("codecsLiveAfterMix", liveAfterMix)
304
+ .put("codecsPeakLive", codecs.optInt("peakLive", -1))
305
+ .put("priming", trimmed)
306
+ .toString()
307
+ }
308
+
309
+ /**
310
+ * Stop whatever is preparing.
311
+ *
312
+ * Bumping the generation is the whole mechanism: the running job's
313
+ * `keepGoing` closure compares against it and unwinds at its next
314
+ * checkpoint.
315
+ */
316
+ fun cancel(): String {
317
+ PreviewTrace.record("mix.cancel") {
318
+ JSONObject().put("job", currentJob ?: "").put("gen", generation.get())
319
+ }
320
+ generation.incrementAndGet()
321
+ val was = currentJob
322
+ currentJob = null
323
+ return JSONObject().put("ok", true).put("cancelled", was ?: "").toString()
324
+ }
325
+
326
+ /** Drop one prepared mix. */
327
+ fun release(context: Context, key: String): String {
328
+ if (!safeKey(key)) {
329
+ return JSONObject().put("ok", false).put("error", "unsafe key").toString()
330
+ }
331
+ val file = File(dir(context), key + SUFFIX)
332
+ val removed = file.exists() && file.delete()
333
+ return JSONObject().put("ok", true).put("removed", removed).toString()
334
+ }
335
+
336
+ /**
337
+ * Remove every prepared mix except the ones named, and every partial.
338
+ *
339
+ * The bound on the cache. A caller keeps the key it is playing and lets the
340
+ * rest go; nothing here decides on its own that a file is stale, for the
341
+ * same reason nothing deletes a managed source implicitly.
342
+ */
343
+ fun sweep(context: Context, keep: JSONArray): String {
344
+ val keepSet = HashSet<String>(keep.length())
345
+ for (i in 0 until keep.length()) keepSet.add(keep.optString(i))
346
+
347
+ var removed = 0
348
+ var kept = 0
349
+ var partials = 0
350
+ var skipped = 0
351
+ for (file in dir(context).listFiles() ?: emptyArray()) {
352
+ val name = file.name
353
+ when {
354
+ name.endsWith(PART_SUFFIX) -> if (file.delete()) partials += 1
355
+ name.endsWith(SUFFIX) && safeKey(name.removeSuffix(SUFFIX)) -> {
356
+ if (keepSet.contains(name.removeSuffix(SUFFIX))) kept += 1
357
+ else if (file.delete()) removed += 1
358
+ }
359
+ // Not ours, and therefore not ours to delete — the rule the
360
+ // managed-source sweep had to learn the hard way.
361
+ else -> skipped += 1
362
+ }
363
+ }
364
+ return JSONObject()
365
+ .put("ok", true)
366
+ .put("removed", removed)
367
+ .put("kept", kept)
368
+ .put("partials", partials)
369
+ .put("skipped", skipped)
370
+ .toString()
371
+ }
372
+
373
+ /** What is prepared, for assertions. */
374
+ fun ledger(context: Context): String {
375
+ val items = JSONArray()
376
+ var bytes = 0L
377
+ for (file in (dir(context).listFiles() ?: emptyArray()).sortedBy { it.name }) {
378
+ bytes += file.length()
379
+ items.put(JSONObject().put("name", file.name).put("byteLength", file.length()))
380
+ }
381
+ return JSONObject()
382
+ .put("ok", true)
383
+ .put("count", items.length())
384
+ .put("bytes", bytes)
385
+ .put("items", items)
386
+ .toString()
387
+ }
388
+
389
+ /** The executor the module runs [prepare] on. */
390
+ fun submit(task: () -> Unit) {
391
+ executor.execute(task)
392
+ }
393
+
394
+ private fun safeKey(key: String): Boolean =
395
+ key.length == 64 && key.all { it in '0'..'9' || it in 'a'..'f' }
396
+ }