@pictor-sdk/video-android 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,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
|
+
}
|