@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,419 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.ContentValues
4
+ import android.content.Context
5
+ import android.media.MediaExtractor
6
+ import android.media.MediaFormat
7
+ import android.net.Uri
8
+ import android.provider.MediaStore
9
+ import org.json.JSONObject
10
+ import java.io.File
11
+ import java.io.FileInputStream
12
+ import java.text.SimpleDateFormat
13
+ import java.util.Date
14
+ import java.util.Locale
15
+ import java.util.concurrent.atomic.AtomicBoolean
16
+
17
+ /**
18
+ * Publishing a finished export to the device's media collection.
19
+ *
20
+ * ## Pending, then published — never in between
21
+ *
22
+ * A row inserted with `IS_PENDING = 1` is visible to this app and to nothing
23
+ * else: not the gallery, not the media scanner, not another app's picker. The
24
+ * bytes stream into it, the result is opened and checked, and only then does
25
+ * `IS_PENDING = 0` make it real. Writing a visible row and filling it
26
+ * afterwards would put a truncated video in the user's gallery for as long as
27
+ * the copy takes, and leave one there permanently if the copy failed.
28
+ *
29
+ * Every unsuccessful path deletes the row. A pending row nobody deletes is
30
+ * invisible and still occupies storage, which is the worst of both.
31
+ *
32
+ * ## There is no legacy branch, and that is a decision
33
+ *
34
+ * `IS_PENDING` and `RELATIVE_PATH` arrived in API 29, and this module's
35
+ * `minSdk` is 29 — checked, not assumed. So the pre-scoped-storage path
36
+ * (`WRITE_EXTERNAL_STORAGE`, a real path under `Environment`, a scanner poke)
37
+ * is not a branch this can reach on any supported device. It is **rejected
38
+ * rather than written**: an untestable branch guarding a version nobody runs is
39
+ * a liability, and lowering `minSdk` to make it reachable would be changing the
40
+ * module's support claim to justify code.
41
+ *
42
+ * ## What it will not do
43
+ *
44
+ * Delete a published item. Release is the *temporary's* business; once a row is
45
+ * published it belongs to the user and to the media store, and this never
46
+ * removes one — including on the paths that clean up after themselves.
47
+ */
48
+ internal object MediaStoreSave {
49
+
50
+ /** Where a saved export lands. One folder, so a sweep can be specific. */
51
+ private const val RELATIVE_PATH = "Movies/Pictor"
52
+
53
+ /**
54
+ * The stored form of {@link RELATIVE_PATH}.
55
+ *
56
+ * MediaStore normalises a relative path to a trailing slash, so a sweep that
57
+ * compares against the unslashed constant matches nothing. Compared exactly
58
+ * rather than by prefix: `Movies/PictorOther/` starts with `Movies/Pictor`.
59
+ */
60
+ private const val RELATIVE_PATH_STORED = "Movies/Pictor/"
61
+
62
+ /**
63
+ * The names this writes, and only those.
64
+ *
65
+ * A file a user renamed, or one another tool put in the same folder, is not
66
+ * this app's to delete even while it is pending.
67
+ */
68
+ private val OWN_NAME = Regex("""^Pictor-\d{8}-\d{6}(?: \(\d+\))?\.mp4$""")
69
+ private const val MIME = "video/mp4"
70
+
71
+ /**
72
+ * Gate-only: hold inside the copy, so a kill can land there.
73
+ *
74
+ * A 3.5 MB copy is a few hundred milliseconds and `am force-stop` takes
75
+ * longer than that to take effect, so the stale-pending-row case — the one
76
+ * the startup sweep exists for — cannot be produced by timing. Inert unless
77
+ * the build has diagnostics: see [Diagnostics].
78
+ */
79
+ @Volatile var holdInCopy: Boolean = false
80
+
81
+ /** Cancellation, per job id, so a cancel cannot stop a different save. */
82
+ private val cancellations = HashMap<String, AtomicBoolean>()
83
+
84
+ private fun flagFor(jobId: String): AtomicBoolean =
85
+ synchronized(cancellations) { cancellations.getOrPut(jobId) { AtomicBoolean(false) } }
86
+
87
+ fun cancel(jobId: String): Boolean =
88
+ synchronized(cancellations) { cancellations[jobId]?.getAndSet(true) != null }
89
+
90
+ private fun forget(jobId: String) {
91
+ synchronized(cancellations) { cancellations.remove(jobId) }
92
+ }
93
+
94
+ /**
95
+ * A name that is the user's, and unique enough not to collide.
96
+ *
97
+ * MediaStore will disambiguate a duplicate by appending `(1)`, but relying
98
+ * on that makes two exports a second apart indistinguishable in a gallery.
99
+ * The timestamp is local because the name is read by a person.
100
+ */
101
+ private fun displayName(): String {
102
+ val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
103
+ return "Pictor-$stamp.mp4"
104
+ }
105
+
106
+ /**
107
+ * Insert pending, stream, verify, publish. One call, settles once.
108
+ *
109
+ * Returns the published `content:` uri, or an error. On every unsuccessful
110
+ * path — including cancellation — the pending row is deleted before
111
+ * returning, so nothing partial survives.
112
+ */
113
+ fun save(context: Context, jobId: String, sourcePath: String): String {
114
+ val out = JSONObject()
115
+ val cancelled = flagFor(jobId)
116
+ val resolver = context.contentResolver
117
+ val source = File(sourcePath)
118
+ if (!source.exists() || source.length() == 0L) {
119
+ forget(jobId)
120
+ // Named, not pathed: the caller knows what it asked to save.
121
+ return out.put("ok", false).put("error", "the export file is no longer available").toString()
122
+ }
123
+
124
+ val name = displayName()
125
+ val values = ContentValues().apply {
126
+ put(MediaStore.Video.Media.DISPLAY_NAME, name)
127
+ put(MediaStore.Video.Media.MIME_TYPE, MIME)
128
+ put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
129
+ // Invisible until the bytes are there and checked.
130
+ put(MediaStore.Video.Media.IS_PENDING, 1)
131
+ }
132
+
133
+ var uri: Uri? = null
134
+ try {
135
+ if (cancelled.get()) return cleanup(resolver, null, jobId, out, "cancelled")
136
+ uri = resolver.insert(MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
137
+ ?: return cleanup(resolver, null, jobId, out, "the media collection refused a new item")
138
+
139
+ /*
140
+ Streamed, and checked between blocks.
141
+
142
+ A cancel that can only be observed once the copy finishes is not a
143
+ cancel, and a large export is seconds of copying.
144
+ */
145
+ var copied = 0L
146
+ resolver.openOutputStream(uri, "w")?.use { output ->
147
+ FileInputStream(source).use { input ->
148
+ val buffer = ByteArray(256 * 1024)
149
+ while (true) {
150
+ if (cancelled.get()) return cleanup(resolver, uri, jobId, out, "cancelled")
151
+ val read = input.read(buffer)
152
+ if (read <= 0) break
153
+ output.write(buffer, 0, read)
154
+ copied += read
155
+ if (Diagnostics.ENABLED && holdInCopy) {
156
+ // Pending row inserted, bytes partly written, and
157
+ // now waiting to be killed. Exactly the state a
158
+ // process death leaves behind.
159
+ output.flush()
160
+ while (true) Thread.sleep(200)
161
+ }
162
+ }
163
+ output.flush()
164
+ }
165
+ } ?: return cleanup(resolver, uri, jobId, out, "the media collection could not be written to")
166
+
167
+ if (copied != source.length()) {
168
+ // Short write with no exception: a full disk can end a stream
169
+ // quietly, and a row this size would play as a truncated video.
170
+ return cleanup(resolver, uri, jobId, out, "the file could not be written completely")
171
+ }
172
+ if (cancelled.get()) return cleanup(resolver, uri, jobId, out, "cancelled")
173
+
174
+ val probe = probe(context, uri)
175
+ if (!probe.optBoolean("readable", false)) {
176
+ return cleanup(
177
+ resolver, uri, jobId, out,
178
+ "the saved file could not be read back",
179
+ )
180
+ }
181
+
182
+ // Published. From here the item is the user's, and nothing in this
183
+ // object removes it.
184
+ val publish = ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }
185
+ resolver.update(uri, publish, null, null)
186
+ forget(jobId)
187
+ return out
188
+ .put("ok", true)
189
+ .put("uri", uri.toString())
190
+ .put("displayName", name)
191
+ .put("location", RELATIVE_PATH)
192
+ .put("bytes", copied)
193
+ .put("durationUs", probe.optLong("durationUs"))
194
+ .put("hasVideo", probe.optBoolean("hasVideo"))
195
+ .put("hasAudio", probe.optBoolean("hasAudio"))
196
+ .toString()
197
+ } catch (e: Exception) {
198
+ return cleanup(resolver, uri, jobId, out, describe(e))
199
+ }
200
+ }
201
+
202
+ /** Delete the pending row, then report. Never reached for a published one. */
203
+ private fun cleanup(
204
+ resolver: android.content.ContentResolver,
205
+ uri: Uri?,
206
+ jobId: String,
207
+ out: JSONObject,
208
+ reason: String,
209
+ ): String {
210
+ if (uri != null) runCatching { resolver.delete(uri, null, null) }
211
+ forget(jobId)
212
+ return out
213
+ .put("ok", false)
214
+ .put("cancelled", reason == "cancelled")
215
+ .put("error", reason)
216
+ .toString()
217
+ }
218
+
219
+ /**
220
+ * The exception's kind, never its message.
221
+ *
222
+ * A platform message routinely contains the path it failed on, and this
223
+ * text reaches a log and a host. `ENOSPC` arrives as an `IOException` whose
224
+ * message names the file.
225
+ */
226
+ private fun describe(e: Exception): String = when (e) {
227
+ is java.io.IOException -> "the file could not be written; the device may be out of space"
228
+ is SecurityException -> "permission to write to the media collection was refused"
229
+ else -> "saving failed (" + e.javaClass.simpleName + ")"
230
+ }
231
+
232
+ /** Opened through the resolver, because a `content:` uri is not a path. */
233
+ private fun probe(context: Context, uri: Uri): JSONObject {
234
+ val result = JSONObject()
235
+ var extractor: MediaExtractor? = null
236
+ try {
237
+ context.contentResolver.openFileDescriptor(uri, "r").use { descriptor ->
238
+ if (descriptor == null) return result.put("readable", false)
239
+ extractor = MediaExtractor()
240
+ extractor!!.setDataSource(descriptor.fileDescriptor)
241
+ var video = false
242
+ var audio = false
243
+ var durationUs = 0L
244
+ for (i in 0 until extractor!!.trackCount) {
245
+ val format = extractor!!.getTrackFormat(i)
246
+ val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
247
+ if (mime.startsWith("video/")) video = true
248
+ if (mime.startsWith("audio/")) audio = true
249
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
250
+ durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
251
+ }
252
+ }
253
+ result.put("readable", video)
254
+ .put("tracks", extractor!!.trackCount)
255
+ .put("hasVideo", video)
256
+ .put("hasAudio", audio)
257
+ .put("durationUs", durationUs)
258
+ }
259
+ } catch (e: Exception) {
260
+ result.put("readable", false).put("error", e.javaClass.simpleName)
261
+ } finally {
262
+ runCatching { extractor?.release() }
263
+ }
264
+ return result
265
+ }
266
+
267
+ /**
268
+ * Remove pending rows a killed process left behind.
269
+ *
270
+ * Four conditions, all required, because this deletes things:
271
+ *
272
+ * - `IS_PENDING = 1` — a published item is never a candidate;
273
+ * - `OWNER_PACKAGE_NAME` is this package — not "probably ours because of
274
+ * where it is";
275
+ * - the relative path is exactly `Movies/Pictor/` — not a prefix, since
276
+ * `Movies/PictorOther/` starts with the same characters;
277
+ * - the display name matches the pattern this code writes.
278
+ *
279
+ * The media store's own visibility rules would hide most foreign pending
280
+ * rows anyway, but relying on that would make the predicate depend on a
281
+ * platform behaviour rather than state it. A row in this folder, with this
282
+ * name, that somebody else wrote is still not this app's to remove.
283
+ */
284
+ fun sweepPending(context: Context): String {
285
+ val resolver = context.contentResolver
286
+ val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
287
+ var swept = 0
288
+ var inspected = 0
289
+ var skipped = 0
290
+ try {
291
+ /*
292
+ Pending rows have to be asked for.
293
+
294
+ From API 30 a plain query excludes them, so the obvious
295
+ `IS_PENDING = 1` selection matches nothing — including the app's
296
+ own. Measured: a row left by a killed process was invisible to the
297
+ very sweep written to collect it, and the sweep reported success
298
+ with nothing found. `QUERY_ARG_MATCH_PENDING` is what makes them
299
+ visible, and only ever the caller's own.
300
+ */
301
+ val args = android.os.Bundle().apply {
302
+ putString(
303
+ android.content.ContentResolver.QUERY_ARG_SQL_SELECTION,
304
+ "${MediaStore.Video.Media.IS_PENDING} = 1",
305
+ )
306
+ putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE)
307
+ }
308
+ resolver.query(
309
+ collection,
310
+ arrayOf(
311
+ MediaStore.Video.Media._ID,
312
+ MediaStore.Video.Media.IS_PENDING,
313
+ MediaStore.Video.Media.RELATIVE_PATH,
314
+ MediaStore.Video.Media.DISPLAY_NAME,
315
+ MediaStore.Video.Media.OWNER_PACKAGE_NAME,
316
+ ),
317
+ args,
318
+ null,
319
+ )?.use { cursor ->
320
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
321
+ val pendingColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.IS_PENDING)
322
+ val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.RELATIVE_PATH)
323
+ val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
324
+ val ownerColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.OWNER_PACKAGE_NAME)
325
+ val ours = context.packageName
326
+ while (cursor.moveToNext()) {
327
+ inspected += 1
328
+ // Each condition restated against the row, not inferred from
329
+ // the selection: a query argument is easy to get wrong, and
330
+ // the cost of being wrong here is deleting someone's file.
331
+ if (cursor.getInt(pendingColumn) != 1) continue
332
+ if (cursor.getString(ownerColumn) != ours) { skipped += 1; continue }
333
+ if (cursor.getString(pathColumn) != RELATIVE_PATH_STORED) { skipped += 1; continue }
334
+ if (!OWN_NAME.matches(cursor.getString(nameColumn) ?: "")) { skipped += 1; continue }
335
+ val row = android.content.ContentUris.withAppendedId(collection, cursor.getLong(idColumn))
336
+ if (runCatching { resolver.delete(row, null, null) }.getOrDefault(0) > 0) swept += 1
337
+ }
338
+ }
339
+ } catch (e: Exception) {
340
+ return JSONObject().put("ok", false).put("error", e.javaClass.simpleName).toString()
341
+ }
342
+ return JSONObject().put("ok", true)
343
+ .put("swept", swept)
344
+ .put("inspected", inspected)
345
+ // Rows seen and deliberately left: the number that proves the
346
+ // predicate is doing something rather than matching everything.
347
+ .put("skipped", skipped)
348
+ .toString()
349
+ }
350
+
351
+ /**
352
+ * Hand a saved video to whatever the user picks.
353
+ *
354
+ * `ACTION_SEND` with a chooser, and the **durable `content:` uri** — never
355
+ * the cache path the export produced. A `file:` uri would raise
356
+ * `FileUriExposedException` on any modern target, and even where it did not
357
+ * it would be a path into this app's private storage that the receiver
358
+ * cannot open.
359
+ *
360
+ * `FLAG_GRANT_READ_URI_PERMISSION` and nothing else. The receiver needs to
361
+ * read the video; it has no business writing to the user's gallery, and a
362
+ * write grant handed out for a share is a permission nobody asked for.
363
+ *
364
+ * The item is untouched. Sharing does not copy, rename, re-publish or
365
+ * release anything — the same row is offered every time.
366
+ */
367
+ fun share(context: Context, uri: String): String {
368
+ val target = Uri.parse(uri)
369
+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
370
+ type = MIME
371
+ putExtra(android.content.Intent.EXTRA_STREAM, target)
372
+ /*
373
+ `ClipData` as well as the extra.
374
+
375
+ The grant travels with the clip data, and a chooser that rebuilds
376
+ the intent for the selected target can lose an extra-only uri —
377
+ which produces a receiver that is handed a uri it cannot open.
378
+ */
379
+ clipData = android.content.ClipData.newUri(context.contentResolver, "video", target)
380
+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
381
+ }
382
+ val chooser = android.content.Intent.createChooser(send, null).apply {
383
+ // The chooser is started from outside an Activity context.
384
+ addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
385
+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
386
+ }
387
+ val describe = JSONObject()
388
+ .put("action", send.action)
389
+ .put("type", send.type)
390
+ .put("uri", target.toString())
391
+ .put("hasClipData", send.clipData != null)
392
+ .put("clipUri", send.clipData?.getItemAt(0)?.uri?.toString())
393
+ .put("grantsRead", send.flags and android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION != 0)
394
+ .put("grantsWrite", send.flags and android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0)
395
+ /*
396
+ No compatible app is not an error.
397
+
398
+ A device with nothing that accepts video/mp4 is unusual and possible,
399
+ and the saved video is untouched either way — so this reports the
400
+ absence and lets the UI say so.
401
+ */
402
+ val resolved = context.packageManager.queryIntentActivities(send, 0)
403
+ if (resolved.isEmpty()) {
404
+ return describe.put("ok", false).put("noTargets", true)
405
+ .put("error", "no app on this device can share a video").toString()
406
+ }
407
+ return try {
408
+ context.startActivity(chooser)
409
+ describe.put("ok", true).put("targets", resolved.size).toString()
410
+ } catch (e: Exception) {
411
+ describe.put("ok", false)
412
+ .put("error", "the share sheet could not be opened").toString()
413
+ }
414
+ }
415
+
416
+ /** Gate-only support: is this uri still readable? Used after a restart. */
417
+ fun readable(context: Context, uri: String): String =
418
+ probe(context, Uri.parse(uri)).toString()
419
+ }
@@ -0,0 +1,169 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import java.util.zip.CRC32
6
+ import org.json.JSONArray
7
+
8
+ /**
9
+ * Overlay rasters, loaded and checked **before Transformer starts**.
10
+ *
11
+ * The backend has no Skia and no fonts. It receives tightly cropped RGBA
12
+ * bitmaps that a renderer already produced, and its whole contribution is to put
13
+ * them where the scene says. Nothing here lays anything out.
14
+ *
15
+ * ## Everything is verified up front
16
+ *
17
+ * A resource that is missing, truncated, altered, duplicated, oversized or the
18
+ * wrong shape fails the run before an encoder exists. Discovering it mid-export
19
+ * means a partial file and an encoder to unwind, and — worse — a frame that
20
+ * quietly lacks something the timeline said was on screen. Nothing downstream
21
+ * can tell that from a video that never had it.
22
+ */
23
+ internal class OverlayResources private constructor(
24
+ val entries: Map<String, Entry>,
25
+ ) {
26
+ class Entry(
27
+ val resourceId: String,
28
+ val overlayId: String,
29
+ val projectWidth: Float,
30
+ val projectHeight: Float,
31
+ val rasterWidth: Int,
32
+ val rasterHeight: Int,
33
+ /** Visual bounds in overlay-local project units. x/y may be negative. */
34
+ val boundsX: Float,
35
+ val boundsY: Float,
36
+ val boundsWidth: Float,
37
+ val boundsHeight: Float,
38
+ val premultiplied: Boolean,
39
+ val pixels: ByteArray,
40
+ )
41
+
42
+ class Invalid(val reason: String)
43
+
44
+ companion object {
45
+ /**
46
+ * Refuse anything larger than this per raster.
47
+ *
48
+ * A texture bigger than any GL implementation guarantees, or a sidecar
49
+ * large enough to be an allocation problem on its own, is a refusal
50
+ * rather than an out-of-memory somewhere later with no context.
51
+ */
52
+ const val MAX_DIMENSION = 4096
53
+ const val MAX_BYTES = 4096L * 4096L * 4L
54
+
55
+ fun parse(context: Context, json: String): Pair<OverlayResources?, Invalid?> {
56
+ val array = try {
57
+ JSONArray(json)
58
+ } catch (e: Exception) {
59
+ return null to Invalid("malformed overlay manifest: ${e.message}")
60
+ }
61
+ val entries = LinkedHashMap<String, Entry>()
62
+ for (i in 0 until array.length()) {
63
+ val o = array.optJSONObject(i)
64
+ ?: return null to Invalid("overlay resource $i is not an object")
65
+ val resourceId = o.optString("resourceId", "")
66
+ if (resourceId.isEmpty()) return null to Invalid("overlay resource $i has no resourceId")
67
+ if (entries.containsKey(resourceId)) {
68
+ return null to Invalid("duplicate overlay resourceId '$resourceId'")
69
+ }
70
+ val overlayId = o.optString("overlayId", "")
71
+ if (overlayId.isEmpty()) return null to Invalid("resource '$resourceId' has no overlayId")
72
+
73
+ if (o.optString("format", "") != "rgba8888") {
74
+ return null to Invalid("resource '$resourceId' is not rgba8888")
75
+ }
76
+ if (o.optString("colorSpace", "") != "srgb") {
77
+ return null to Invalid("resource '$resourceId' is not srgb")
78
+ }
79
+ val rw = o.optInt("rasterWidth", 0)
80
+ val rh = o.optInt("rasterHeight", 0)
81
+ if (rw <= 0 || rh <= 0) return null to Invalid("resource '$resourceId' has no raster size")
82
+ if (rw > MAX_DIMENSION || rh > MAX_DIMENSION) {
83
+ return null to Invalid("resource '$resourceId' is ${rw}x$rh, over the $MAX_DIMENSION limit")
84
+ }
85
+ /*
86
+ Bounds, not just a size.
87
+
88
+ A stroke, a blur or a shadow draws outside the measured box, so
89
+ the raster is wider than `size` and starts at a negative
90
+ offset. Placing it on `size` would shift it by exactly the
91
+ bleed; placing it on bounds and anchoring on `size` is the only
92
+ arrangement where neither is wrong.
93
+ */
94
+ val bounds = o.optJSONObject("rasterBounds")
95
+ ?: return null to Invalid("resource '$resourceId' has no rasterBounds")
96
+ val bx = bounds.optDouble("x", Double.NaN)
97
+ val by = bounds.optDouble("y", Double.NaN)
98
+ val bw = bounds.optDouble("width", Double.NaN)
99
+ val bh = bounds.optDouble("height", Double.NaN)
100
+ if (bx.isNaN() || by.isNaN()) {
101
+ return null to Invalid("resource '$resourceId' rasterBounds origin is not finite")
102
+ }
103
+ if (bw.isNaN() || bh.isNaN() || bw <= 0 || bh <= 0) {
104
+ return null to Invalid("resource '$resourceId' rasterBounds is empty")
105
+ }
106
+ val scale = o.optDouble("rasterScale", Double.NaN)
107
+ if (scale.isNaN() || scale <= 0) {
108
+ return null to Invalid("resource '$resourceId' rasterScale is not positive")
109
+ }
110
+ // The raster must be the size its bounds and scale imply, or a
111
+ // fraction-of-a-pixel shift is baked into every frame.
112
+ val impliedW = Math.round(bw * scale).toInt()
113
+ val impliedH = Math.round(bh * scale).toInt()
114
+ if (rw != impliedW || rh != impliedH) {
115
+ return null to Invalid(
116
+ "resource '$resourceId' raster is ${rw}x$rh, but bounds imply ${impliedW}x$impliedH",
117
+ )
118
+ }
119
+
120
+ val size = o.optJSONObject("size")
121
+ ?: return null to Invalid("resource '$resourceId' has no project size")
122
+ val pw = size.optDouble("width", Double.NaN)
123
+ val ph = size.optDouble("height", Double.NaN)
124
+ if (pw.isNaN() || ph.isNaN() || pw <= 0 || ph <= 0) {
125
+ return null to Invalid("resource '$resourceId' project size is not positive")
126
+ }
127
+
128
+ val path = o.optString("file", "")
129
+ if (path.isEmpty()) return null to Invalid("resource '$resourceId' has no file")
130
+ val file = File(path)
131
+ if (!file.exists()) return null to Invalid("resource '$resourceId' file is missing")
132
+ val declared = o.optLong("byteLength", -1)
133
+ val expected = rw.toLong() * rh.toLong() * 4L
134
+ if (declared != expected) {
135
+ return null to Invalid(
136
+ "resource '$resourceId' declares $declared bytes, not ${rw}x${rh}x4 = $expected",
137
+ )
138
+ }
139
+ if (expected > MAX_BYTES) return null to Invalid("resource '$resourceId' exceeds the byte limit")
140
+ if (file.length() != expected) {
141
+ // Truncated or padded: the file and the descriptor disagree
142
+ // about what was produced, and neither can be trusted.
143
+ return null to Invalid(
144
+ "resource '$resourceId' file is ${file.length()} bytes, not $expected",
145
+ )
146
+ }
147
+ val bytes = try {
148
+ file.readBytes()
149
+ } catch (e: Exception) {
150
+ return null to Invalid("resource '$resourceId' file unreadable: ${e.message}")
151
+ }
152
+ val crc = CRC32()
153
+ crc.update(bytes)
154
+ val declaredCrc = o.optLong("checksum", -1)
155
+ if (crc.value != declaredCrc) {
156
+ return null to Invalid(
157
+ "resource '$resourceId' checksum ${crc.value} does not match declared $declaredCrc",
158
+ )
159
+ }
160
+ entries[resourceId] = Entry(
161
+ resourceId, overlayId, pw.toFloat(), ph.toFloat(), rw, rh,
162
+ bx.toFloat(), by.toFloat(), bw.toFloat(), bh.toFloat(),
163
+ o.optBoolean("premultiplied", false), bytes,
164
+ )
165
+ }
166
+ return OverlayResources(entries) to null
167
+ }
168
+ }
169
+ }