@pictor-sdk/video-android 0.1.0 → 0.1.4
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/android/build.gradle +36 -9
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +15 -1
- package/android/src/main/cpp/pictor_videosurface.cpp +88 -8
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +39 -4
- package/android/src/main/java/dev/pictor/video/BoundedWait.kt +89 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +29 -0
- package/android/src/main/java/dev/pictor/video/CompositionSeek.kt +8 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +23 -13
- package/android/src/main/java/dev/pictor/video/CompositionVideoItem.kt +16 -0
- package/android/src/main/java/dev/pictor/video/CompositionWindow.kt +17 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +421 -44
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +6 -1
- package/android/src/main/java/dev/pictor/video/GateLadder.kt +405 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +216 -9
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +13 -0
- package/android/src/main/java/dev/pictor/video/RunRegistry.kt +108 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +4 -0
- package/dist/index.d.mts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +15 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/plugin/gradleProperties.js +9 -6
- package/src/NativePictorVideo.ts +11 -0
- package/src/compositionSpike.ts +9 -0
- package/src/exporter.ts +41 -3
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.HandlerThread
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import androidx.media3.common.C
|
|
8
|
+
import androidx.media3.common.Effect
|
|
9
|
+
import androidx.media3.common.MediaItem
|
|
10
|
+
import androidx.media3.common.audio.SpeedProvider
|
|
11
|
+
import androidx.media3.common.util.UnstableApi
|
|
12
|
+
import androidx.media3.transformer.Composition
|
|
13
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
14
|
+
import androidx.media3.transformer.EditedMediaItemSequence
|
|
15
|
+
import androidx.media3.transformer.ExportException
|
|
16
|
+
import androidx.media3.transformer.ExportResult
|
|
17
|
+
import androidx.media3.transformer.Effects
|
|
18
|
+
import androidx.media3.transformer.Transformer
|
|
19
|
+
import com.google.common.collect.ImmutableList
|
|
20
|
+
import java.io.File
|
|
21
|
+
import java.util.concurrent.CountDownLatch
|
|
22
|
+
import java.util.concurrent.TimeUnit
|
|
23
|
+
import org.json.JSONArray
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The smallest Media3 export that fails, found one stage at a time.
|
|
28
|
+
*
|
|
29
|
+
* ## Why this is not `ExportGate`
|
|
30
|
+
*
|
|
31
|
+
* `ExportGate.runSequence` can already be told to add or omit each stage, and
|
|
32
|
+
* using it would have been less code. It would also have kept every one of the
|
|
33
|
+
* things this is trying to rule out: the run registry, the dedicated
|
|
34
|
+
* Transformer looper, the heartbeat, the resource ledger, the stall watchdog,
|
|
35
|
+
* the channel mixer, the forced audio track, the temporary-file bookkeeping and
|
|
36
|
+
* the concatenation that follows. A reproduction that carries the suspect along
|
|
37
|
+
* with it is not a reproduction.
|
|
38
|
+
*
|
|
39
|
+
* So rung 1 here is a plain `Transformer` over one clipped `MediaItem` with no
|
|
40
|
+
* effects and no speed — about twenty lines of Media3, and nothing of Pictor's.
|
|
41
|
+
* Each later rung adds exactly one stage to that, and every rung runs
|
|
42
|
+
* independently against its own uniquely named output. The first rung that does
|
|
43
|
+
* not complete is the answer.
|
|
44
|
+
*
|
|
45
|
+
* ## Rates and spans are given, never derived
|
|
46
|
+
*
|
|
47
|
+
* A slider label is not a rate. The caller passes the configured speed, the
|
|
48
|
+
* source span in microseconds, and — for the cadence rungs — the emission plan
|
|
49
|
+
* `resampleClip` compiled in TypeScript, so what runs here is what production
|
|
50
|
+
* would have run rather than a second opinion about it.
|
|
51
|
+
*
|
|
52
|
+
* Diagnostics-only, like the rest of the gate family: the entry point refuses
|
|
53
|
+
* on its first statement without [Diagnostics.ENABLED].
|
|
54
|
+
*/
|
|
55
|
+
@UnstableApi
|
|
56
|
+
internal object GateLadder {
|
|
57
|
+
|
|
58
|
+
/** What one rung is allowed to switch on. Nothing else varies between them. */
|
|
59
|
+
private class Rung(
|
|
60
|
+
val name: String,
|
|
61
|
+
val speed: Boolean,
|
|
62
|
+
val cadence: Boolean,
|
|
63
|
+
val skia: Boolean,
|
|
64
|
+
/**
|
|
65
|
+
* The audio processors `ExportGate` puts on every shard.
|
|
66
|
+
*
|
|
67
|
+
* A channel mixer, so shards can be concatenated by copying samples,
|
|
68
|
+
* and Sonic with the reciprocal pitch, so a rate change does not turn a
|
|
69
|
+
* voice into a tape effect. Neither is about the picture, and both sit
|
|
70
|
+
* on the audio path a sped-up item is re-timed through — which is the
|
|
71
|
+
* one part of a production shard the rungs above still do not have.
|
|
72
|
+
*/
|
|
73
|
+
val audioProcessors: String = "none",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
private val RUNGS = listOf(
|
|
77
|
+
// The floor: is a plain transcode of this span sound at all?
|
|
78
|
+
Rung("plain", speed = false, cadence = false, skia = false),
|
|
79
|
+
// One stage each, against that floor.
|
|
80
|
+
Rung("speed", speed = true, cadence = false, skia = false),
|
|
81
|
+
Rung("cadence", speed = false, cadence = true, skia = false),
|
|
82
|
+
Rung("skia", speed = false, cadence = false, skia = true),
|
|
83
|
+
// Then the pairs, and finally what production actually builds.
|
|
84
|
+
Rung("speed+cadence", speed = true, cadence = true, skia = false),
|
|
85
|
+
Rung("speed+cadence+skia", speed = true, cadence = true, skia = true),
|
|
86
|
+
// Everything a production shard's Transformer is built with, and then
|
|
87
|
+
// each of the two audio processors on its own — because "both of them
|
|
88
|
+
// together" names a pair, not a cause.
|
|
89
|
+
Rung("production", speed = true, cadence = true, skia = true, audioProcessors = "both"),
|
|
90
|
+
Rung("prod-mixer", speed = true, cadence = true, skia = true, audioProcessors = "mixer"),
|
|
91
|
+
Rung("prod-sonic", speed = true, cadence = true, skia = true, audioProcessors = "sonic"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
/** A constant rate, exactly as `ExportGate` declares one. */
|
|
95
|
+
private class ConstantSpeed(private val speed: Float) : SpeedProvider {
|
|
96
|
+
override fun getSpeed(timeUs: Long): Float = speed
|
|
97
|
+
override fun getNextSpeedChangeTimeUs(timeUs: Long): Long = C.TIME_UNSET
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run the ladder and report every rung.
|
|
102
|
+
*
|
|
103
|
+
* `specJson` is `{ uri, startUs, endUs, speed, timeoutSeconds?,
|
|
104
|
+
* emitsPerInput?: number[], outputPtsUs?: number[], only?: string[] }`.
|
|
105
|
+
*
|
|
106
|
+
* Every rung runs even after one fails. "The first failing configuration"
|
|
107
|
+
* is a claim about an ordering, and stopping at the first failure would
|
|
108
|
+
* leave no evidence about whether the later ones fail for the same reason
|
|
109
|
+
* or a different one.
|
|
110
|
+
*/
|
|
111
|
+
/** The one thread a ladder runs on, so two cannot overlap. */
|
|
112
|
+
private val worker = java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
|
113
|
+
Thread(r, "pictor-ladder").also { it.isDaemon = true }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Volatile
|
|
117
|
+
private var running = false
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Start a ladder in the background and report the result to logcat.
|
|
121
|
+
*
|
|
122
|
+
* Not synchronous, because the caller is the JavaScript thread: six rungs
|
|
123
|
+
* with a bounded wait each is minutes of it, and a blocked JS thread is an
|
|
124
|
+
* app that cannot be observed while the thing under investigation runs. The
|
|
125
|
+
* result arrives as one `PictorLadder` line, which is also how it is read.
|
|
126
|
+
*/
|
|
127
|
+
fun start(context: Context, specJson: String): String {
|
|
128
|
+
if (running) {
|
|
129
|
+
return JSONObject().put("ok", false).put("error", "a ladder is already running")
|
|
130
|
+
.toString()
|
|
131
|
+
}
|
|
132
|
+
running = true
|
|
133
|
+
worker.execute {
|
|
134
|
+
val out = try {
|
|
135
|
+
run(context, specJson)
|
|
136
|
+
} catch (e: Throwable) {
|
|
137
|
+
JSONObject().put("ok", false)
|
|
138
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
139
|
+
} finally {
|
|
140
|
+
running = false
|
|
141
|
+
}
|
|
142
|
+
Log.i("PictorLadder", out)
|
|
143
|
+
}
|
|
144
|
+
return JSONObject().put("ok", true).put("started", true).toString()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fun run(context: Context, specJson: String): String {
|
|
148
|
+
val spec = try {
|
|
149
|
+
JSONObject(specJson)
|
|
150
|
+
} catch (e: Exception) {
|
|
151
|
+
return JSONObject().put("ok", false).put("error", "bad spec: " + e.message).toString()
|
|
152
|
+
}
|
|
153
|
+
val uri = spec.optString("uri")
|
|
154
|
+
if (uri.isEmpty()) {
|
|
155
|
+
return JSONObject().put("ok", false).put("error", "no uri").toString()
|
|
156
|
+
}
|
|
157
|
+
val startUs = spec.optLong("startUs", 0)
|
|
158
|
+
val endUs = spec.optLong("endUs", 0)
|
|
159
|
+
val speed = spec.optDouble("speed", 1.0).toFloat()
|
|
160
|
+
val timeoutSeconds = spec.optLong("timeoutSeconds", 90)
|
|
161
|
+
val emits = spec.optJSONArray("emitsPerInput")?.let { a ->
|
|
162
|
+
IntArray(a.length()) { a.getInt(it) }
|
|
163
|
+
}
|
|
164
|
+
val pts = spec.optJSONArray("outputPtsUs")?.let { a ->
|
|
165
|
+
LongArray(a.length()) { a.getLong(it) }
|
|
166
|
+
}
|
|
167
|
+
val only = spec.optJSONArray("only")?.let { a ->
|
|
168
|
+
(0 until a.length()).map { a.getString(it) }.toSet()
|
|
169
|
+
}
|
|
170
|
+
/*
|
|
171
|
+
The two settings a shard is built with, and the reason they are here.
|
|
172
|
+
|
|
173
|
+
`ExportGate` forces an audio track onto every shard and drops the
|
|
174
|
+
source's own, because Media3 drives its speed path through audio and a
|
|
175
|
+
video-only item at any rate but 1 aborts with "no output sample
|
|
176
|
+
written". A ladder that omitted them would reproduce *that* known
|
|
177
|
+
failure and call it the stall. Both default to off — the minimal run
|
|
178
|
+
is the one with nothing added — and a second run turns them on, so the
|
|
179
|
+
difference between them is a measurement rather than an assumption.
|
|
180
|
+
*/
|
|
181
|
+
val forceAudioTrack = spec.optBoolean("forceAudioTrack", false)
|
|
182
|
+
val removeAudio = spec.optBoolean("removeAudio", false)
|
|
183
|
+
|
|
184
|
+
val results = JSONArray()
|
|
185
|
+
var firstFailure: String? = null
|
|
186
|
+
for (rung in RUNGS) {
|
|
187
|
+
if (only != null && !only.contains(rung.name)) continue
|
|
188
|
+
if (rung.cadence && (emits == null || pts == null)) {
|
|
189
|
+
results.put(
|
|
190
|
+
JSONObject().put("rung", rung.name).put("ok", false)
|
|
191
|
+
.put("error", "no emission plan supplied"),
|
|
192
|
+
)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
val one = runRung(
|
|
196
|
+
context, rung, uri, startUs, endUs, speed, emits, pts, timeoutSeconds,
|
|
197
|
+
forceAudioTrack, removeAudio,
|
|
198
|
+
)
|
|
199
|
+
// Per rung, not only at the end: a ladder killed part-way through
|
|
200
|
+
// still has to have said what it learned before it was.
|
|
201
|
+
Log.i("PictorLadderRung", one.toString())
|
|
202
|
+
results.put(one)
|
|
203
|
+
if (!one.optBoolean("ok") && firstFailure == null) firstFailure = rung.name
|
|
204
|
+
}
|
|
205
|
+
return JSONObject()
|
|
206
|
+
.put("ok", firstFailure == null)
|
|
207
|
+
.put("speed", speed.toDouble())
|
|
208
|
+
.put("startUs", startUs)
|
|
209
|
+
.put("endUs", endUs)
|
|
210
|
+
.put("firstFailure", firstFailure ?: "")
|
|
211
|
+
.put("forceAudioTrack", forceAudioTrack)
|
|
212
|
+
.put("removeAudio", removeAudio)
|
|
213
|
+
.put("rungs", results)
|
|
214
|
+
.toString()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun runRung(
|
|
218
|
+
context: Context,
|
|
219
|
+
rung: Rung,
|
|
220
|
+
uri: String,
|
|
221
|
+
startUs: Long,
|
|
222
|
+
endUs: Long,
|
|
223
|
+
speed: Float,
|
|
224
|
+
emits: IntArray?,
|
|
225
|
+
pts: LongArray?,
|
|
226
|
+
timeoutSeconds: Long,
|
|
227
|
+
forceAudioTrack: Boolean,
|
|
228
|
+
removeAudio: Boolean,
|
|
229
|
+
): JSONObject {
|
|
230
|
+
/*
|
|
231
|
+
A fresh looper per rung, and joined before returning.
|
|
232
|
+
|
|
233
|
+
Not for tidiness: a rung that times out leaves a Transformer holding
|
|
234
|
+
codecs on the thread that built it, and reusing one looper would let
|
|
235
|
+
the next rung's failure be the previous rung's leftovers. Each rung
|
|
236
|
+
gets a thread that is only ever used for it, and whether that thread
|
|
237
|
+
was still alive at the end is reported rather than assumed.
|
|
238
|
+
*/
|
|
239
|
+
val thread = HandlerThread("pictor-ladder-" + rung.name.replace('+', '-'))
|
|
240
|
+
thread.start()
|
|
241
|
+
val handler = Handler(thread.looper)
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
The app's external files directory, not the cache.
|
|
245
|
+
|
|
246
|
+
A rung's output has to be read back off the device, and the build this
|
|
247
|
+
runs in is a release one — not debuggable, so `run-as` cannot reach
|
|
248
|
+
internal storage. The external files directory needs no permission,
|
|
249
|
+
nothing scans it, and `adb pull` can read it. Unique by construction,
|
|
250
|
+
so an older file can never be read as this run's result.
|
|
251
|
+
*/
|
|
252
|
+
val dir = File(context.getExternalFilesDir(null) ?: context.cacheDir, "pictor-ladder")
|
|
253
|
+
dir.mkdirs()
|
|
254
|
+
val output = File.createTempFile(
|
|
255
|
+
"ladder-" + rung.name.replace('+', '-') + "-" + System.nanoTime() + "-", ".mp4", dir,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
val latch = CountDownLatch(1)
|
|
259
|
+
var result: ExportResult? = null
|
|
260
|
+
var failure: ExportException? = null
|
|
261
|
+
var buildError: String? = null
|
|
262
|
+
val cadenceEffect = if (rung.cadence && emits != null && pts != null) {
|
|
263
|
+
CadenceEffect(emits, pts)
|
|
264
|
+
} else {
|
|
265
|
+
null
|
|
266
|
+
}
|
|
267
|
+
val skiaEffect = if (rung.skia) SkiaPassThroughEffect() else null
|
|
268
|
+
|
|
269
|
+
val startedNs = System.nanoTime()
|
|
270
|
+
var transformer: Transformer? = null
|
|
271
|
+
handler.post {
|
|
272
|
+
try {
|
|
273
|
+
val media = MediaItem.Builder()
|
|
274
|
+
.setUri(uri)
|
|
275
|
+
.setClippingConfiguration(
|
|
276
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
277
|
+
.setStartPositionUs(startUs)
|
|
278
|
+
.setEndPositionUs(endUs)
|
|
279
|
+
.build(),
|
|
280
|
+
)
|
|
281
|
+
.build()
|
|
282
|
+
val video = ArrayList<Effect>()
|
|
283
|
+
cadenceEffect?.let { video.add(it) }
|
|
284
|
+
skiaEffect?.let { video.add(it as Effect) }
|
|
285
|
+
val audio = ArrayList<androidx.media3.common.audio.AudioProcessor>()
|
|
286
|
+
if (rung.audioProcessors == "both" || rung.audioProcessors == "mixer") {
|
|
287
|
+
val mixer = androidx.media3.common.audio.ChannelMixingAudioProcessor()
|
|
288
|
+
for (inChannels in 1..2) {
|
|
289
|
+
mixer.putChannelMixingMatrix(
|
|
290
|
+
androidx.media3.common.audio.ChannelMixingMatrix
|
|
291
|
+
.createForConstantPower(inChannels, 2),
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
audio.add(mixer)
|
|
295
|
+
}
|
|
296
|
+
if ((rung.audioProcessors == "both" || rung.audioProcessors == "sonic") &&
|
|
297
|
+
rung.speed
|
|
298
|
+
) {
|
|
299
|
+
// The reciprocal, exactly as `ExportGate` composes it: the
|
|
300
|
+
// resampling moved tempo and pitch together, and this puts
|
|
301
|
+
// the pitch back.
|
|
302
|
+
val sonic = androidx.media3.common.audio.SonicAudioProcessor()
|
|
303
|
+
sonic.setPitch(1f / speed)
|
|
304
|
+
audio.add(sonic)
|
|
305
|
+
}
|
|
306
|
+
val edited = EditedMediaItem.Builder(media)
|
|
307
|
+
.also { if (removeAudio) it.setRemoveAudio(true) }
|
|
308
|
+
.also { if (rung.speed) it.setSpeed(ConstantSpeed(speed)) }
|
|
309
|
+
.setEffects(
|
|
310
|
+
Effects(ImmutableList.copyOf(audio), ImmutableList.copyOf(video)),
|
|
311
|
+
)
|
|
312
|
+
.build()
|
|
313
|
+
val sequence = EditedMediaItemSequence.Builder()
|
|
314
|
+
.also { if (forceAudioTrack) it.experimentalSetForceAudioTrack(true) }
|
|
315
|
+
.addItem(edited)
|
|
316
|
+
.build()
|
|
317
|
+
val composition = Composition.Builder(sequence).build()
|
|
318
|
+
val built = Transformer.Builder(context)
|
|
319
|
+
.addListener(object : Transformer.Listener {
|
|
320
|
+
override fun onCompleted(c: Composition, r: ExportResult) {
|
|
321
|
+
result = r
|
|
322
|
+
latch.countDown()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
override fun onError(c: Composition, r: ExportResult, e: ExportException) {
|
|
326
|
+
failure = e
|
|
327
|
+
latch.countDown()
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
.build()
|
|
331
|
+
transformer = built
|
|
332
|
+
built.start(composition, output.absolutePath)
|
|
333
|
+
} catch (e: Throwable) {
|
|
334
|
+
buildError = e.javaClass.simpleName + ": " + e.message
|
|
335
|
+
latch.countDown()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
val finished = latch.await(timeoutSeconds, TimeUnit.SECONDS)
|
|
340
|
+
val elapsedMs = (System.nanoTime() - startedNs) / 1_000_000
|
|
341
|
+
|
|
342
|
+
/*
|
|
343
|
+
A timed-out rung is cancelled, and the cancel is *not* waited on here.
|
|
344
|
+
|
|
345
|
+
`Transformer.cancel()` joins the internal threads and can itself block
|
|
346
|
+
indefinitely when the encoder never finishes — which is the condition
|
|
347
|
+
under investigation. Posting it and reporting whether the post was
|
|
348
|
+
answered distinguishes "cleaned up" from "still wedged" instead of
|
|
349
|
+
hanging the ladder on the fault it is measuring.
|
|
350
|
+
*/
|
|
351
|
+
var cancelReturned = false
|
|
352
|
+
if (!finished) {
|
|
353
|
+
val cancelled = CountDownLatch(1)
|
|
354
|
+
handler.post {
|
|
355
|
+
try {
|
|
356
|
+
transformer?.cancel()
|
|
357
|
+
} catch (_: Throwable) {
|
|
358
|
+
}
|
|
359
|
+
cancelled.countDown()
|
|
360
|
+
}
|
|
361
|
+
cancelReturned = cancelled.await(10, TimeUnit.SECONDS)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
val report = JSONObject()
|
|
365
|
+
.put("rung", rung.name)
|
|
366
|
+
.put("ok", finished && failure == null && buildError == null)
|
|
367
|
+
.put("elapsedMs", elapsedMs)
|
|
368
|
+
.put("timedOut", !finished)
|
|
369
|
+
.put("cancelReturned", cancelReturned)
|
|
370
|
+
.put(
|
|
371
|
+
"error",
|
|
372
|
+
buildError
|
|
373
|
+
?: failure?.let { it.errorCode.toString() + ": " + (it.cause?.message ?: it.message) }
|
|
374
|
+
?: if (!finished) "timeout after " + timeoutSeconds + "s" else "",
|
|
375
|
+
)
|
|
376
|
+
.put("videoFrameCount", result?.videoFrameCount ?: -1)
|
|
377
|
+
.put("durationMs", result?.durationMs ?: -1)
|
|
378
|
+
.put("videoEncoder", result?.videoEncoderName ?: "")
|
|
379
|
+
.put("audioEncoder", result?.audioEncoderName ?: "")
|
|
380
|
+
.put("outputBytes", if (output.exists()) output.length() else -1)
|
|
381
|
+
.put("output", output.absolutePath)
|
|
382
|
+
.put("cadenceTrace", cadenceEffect?.trace() ?: "")
|
|
383
|
+
.put("skiaFrames", skiaEffect?.framesSeen() ?: -1)
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
Whether the rung's thread is still occupied is part of the result.
|
|
387
|
+
|
|
388
|
+
`quitSafely` returns immediately; the thread only ends once the looper
|
|
389
|
+
drains. A thread still alive a second later is one still inside
|
|
390
|
+
something — a blocked `cancel`, an encoder wait — and that is the
|
|
391
|
+
cleanup evidence a frame count cannot give.
|
|
392
|
+
*/
|
|
393
|
+
thread.quitSafely()
|
|
394
|
+
thread.join(1_000)
|
|
395
|
+
report.put("threadStillAlive", thread.isAlive)
|
|
396
|
+
if (finished && failure == null && buildError == null) {
|
|
397
|
+
report.put("keptOutput", true)
|
|
398
|
+
} else {
|
|
399
|
+
// A failed rung's partial file is not a result; nothing may read it.
|
|
400
|
+
output.delete()
|
|
401
|
+
report.put("keptOutput", false)
|
|
402
|
+
}
|
|
403
|
+
return report
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
package dev.pictor.video
|
|
2
2
|
|
|
3
|
+
import android.Manifest
|
|
3
4
|
import android.content.ContentValues
|
|
4
5
|
import android.content.Context
|
|
6
|
+
import android.content.pm.PackageManager
|
|
5
7
|
import android.media.MediaExtractor
|
|
6
8
|
import android.media.MediaFormat
|
|
9
|
+
import android.media.MediaScannerConnection
|
|
7
10
|
import android.net.Uri
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.Environment
|
|
8
13
|
import android.provider.MediaStore
|
|
14
|
+
import androidx.annotation.RequiresApi
|
|
9
15
|
import org.json.JSONObject
|
|
10
16
|
import java.io.File
|
|
11
17
|
import java.io.FileInputStream
|
|
18
|
+
import java.io.FileOutputStream
|
|
12
19
|
import java.text.SimpleDateFormat
|
|
13
20
|
import java.util.Date
|
|
14
21
|
import java.util.Locale
|
|
22
|
+
import java.util.concurrent.CountDownLatch
|
|
23
|
+
import java.util.concurrent.TimeUnit
|
|
15
24
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
16
25
|
|
|
17
26
|
/**
|
|
@@ -29,15 +38,23 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
29
38
|
* Every unsuccessful path deletes the row. A pending row nobody deletes is
|
|
30
39
|
* invisible and still occupies storage, which is the worst of both.
|
|
31
40
|
*
|
|
32
|
-
* ##
|
|
41
|
+
* ## Two paths, because `IS_PENDING` arrived in API 29
|
|
33
42
|
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* On 29 and above the pending row above is the whole story. Below it there is no
|
|
44
|
+
* `IS_PENDING` and no `RELATIVE_PATH`, so the same guarantee is built out of the
|
|
45
|
+
* filesystem: the bytes stream into a **dot-prefixed** file, which the media
|
|
46
|
+
* scanner ignores, and only a completed, verified file is renamed into place and
|
|
47
|
+
* handed to the scanner. Nothing partial is ever visible on either path, which is
|
|
48
|
+
* the property that matters — the mechanism differs because the platform does.
|
|
49
|
+
*
|
|
50
|
+
* The legacy path needs `WRITE_EXTERNAL_STORAGE`, which is why this module's
|
|
51
|
+
* manifest declares it with `maxSdkVersion="28"`. It disappears on every device
|
|
52
|
+
* that has scoped storage, and the save reports a specific, actionable error
|
|
53
|
+
* rather than throwing when a host has not asked for it.
|
|
54
|
+
*
|
|
55
|
+
* This branch exists on paper until an API 24..28 device or emulator runs it.
|
|
56
|
+
* Until then `minSdk 24` is a *build* claim, not a verified one — see
|
|
57
|
+
* `docs/research/min-sdk-24.md`.
|
|
41
58
|
*
|
|
42
59
|
* ## What it will not do
|
|
43
60
|
*
|
|
@@ -59,6 +76,19 @@ internal object MediaStoreSave {
|
|
|
59
76
|
*/
|
|
60
77
|
private const val RELATIVE_PATH_STORED = "Movies/Pictor/"
|
|
61
78
|
|
|
79
|
+
/** The same folder as {@link RELATIVE_PATH}, as a name under `Movies`. */
|
|
80
|
+
private const val LEGACY_FOLDER = "Pictor"
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What an unfinished legacy save is called.
|
|
84
|
+
*
|
|
85
|
+
* The leading dot is what hides it — the media scanner skips dot-prefixed
|
|
86
|
+
* files — and the suffix is what a sweep matches on. Both are needed: the dot
|
|
87
|
+
* keeps it out of the gallery, the suffix keeps the sweep from deleting
|
|
88
|
+
* something a person put there.
|
|
89
|
+
*/
|
|
90
|
+
private const val LEGACY_PENDING_SUFFIX = ".part"
|
|
91
|
+
|
|
62
92
|
/**
|
|
63
93
|
* The names this writes, and only those.
|
|
64
94
|
*
|
|
@@ -120,6 +150,9 @@ internal object MediaStoreSave {
|
|
|
120
150
|
// Named, not pathed: the caller knows what it asked to save.
|
|
121
151
|
return out.put("ok", false).put("error", "the export file is no longer available").toString()
|
|
122
152
|
}
|
|
153
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
154
|
+
return saveLegacy(context, jobId, source, cancelled, out)
|
|
155
|
+
}
|
|
123
156
|
|
|
124
157
|
val name = displayName()
|
|
125
158
|
val values = ContentValues().apply {
|
|
@@ -199,6 +232,144 @@ internal object MediaStoreSave {
|
|
|
199
232
|
}
|
|
200
233
|
}
|
|
201
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The same guarantee without `IS_PENDING`, for API 24..28.
|
|
237
|
+
*
|
|
238
|
+
* A dot-prefixed file is skipped by the media scanner, so it stands in for
|
|
239
|
+
* the pending row: the bytes stream into `.Pictor-….mp4.part`, the result is
|
|
240
|
+
* verified, and only then is it renamed to its real name and scanned. A
|
|
241
|
+
* half-written video is never in the gallery, and a failed or cancelled save
|
|
242
|
+
* leaves nothing behind — the same two properties the 29+ path has, built
|
|
243
|
+
* out of what the platform offered at the time.
|
|
244
|
+
*
|
|
245
|
+
* The order matters. Renaming before verifying would make a truncated file
|
|
246
|
+
* visible for as long as the check took, and scanning before renaming would
|
|
247
|
+
* index the temporary name.
|
|
248
|
+
*/
|
|
249
|
+
private fun saveLegacy(
|
|
250
|
+
context: Context,
|
|
251
|
+
jobId: String,
|
|
252
|
+
source: File,
|
|
253
|
+
cancelled: AtomicBoolean,
|
|
254
|
+
out: JSONObject,
|
|
255
|
+
): String {
|
|
256
|
+
/*
|
|
257
|
+
Asked for, not assumed.
|
|
258
|
+
|
|
259
|
+
Writing to shared storage below API 29 needs WRITE_EXTERNAL_STORAGE,
|
|
260
|
+
and a host that has not requested it gets a sentence it can act on
|
|
261
|
+
rather than a SecurityException from inside a copy loop. The adapter
|
|
262
|
+
declares the permission (capped at 28); asking the user for it is the
|
|
263
|
+
host's, because the host owns the moment to ask.
|
|
264
|
+
*/
|
|
265
|
+
if (
|
|
266
|
+
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
|
|
267
|
+
PackageManager.PERMISSION_GRANTED
|
|
268
|
+
) {
|
|
269
|
+
forget(jobId)
|
|
270
|
+
return out
|
|
271
|
+
.put("ok", false)
|
|
272
|
+
.put("needsPermission", Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
273
|
+
.put("error", "saving to the gallery on this Android version needs storage permission")
|
|
274
|
+
.toString()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
val folder = File(
|
|
278
|
+
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
|
279
|
+
LEGACY_FOLDER,
|
|
280
|
+
)
|
|
281
|
+
if (!folder.exists() && !folder.mkdirs()) {
|
|
282
|
+
forget(jobId)
|
|
283
|
+
return out.put("ok", false).put("error", "the gallery folder could not be created").toString()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
val name = displayName()
|
|
287
|
+
val pending = File(folder, ".$name$LEGACY_PENDING_SUFFIX")
|
|
288
|
+
val target = File(folder, name)
|
|
289
|
+
|
|
290
|
+
/** Nothing partial survives: the temporary, and the target if we got that far. */
|
|
291
|
+
fun abandon(reason: String): String {
|
|
292
|
+
runCatching { if (pending.exists()) pending.delete() }
|
|
293
|
+
runCatching { if (target.exists()) target.delete() }
|
|
294
|
+
forget(jobId)
|
|
295
|
+
return out
|
|
296
|
+
.put("ok", false)
|
|
297
|
+
.put("cancelled", reason == "cancelled")
|
|
298
|
+
.put("error", reason)
|
|
299
|
+
.toString()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
if (cancelled.get()) return abandon("cancelled")
|
|
304
|
+
|
|
305
|
+
var copied = 0L
|
|
306
|
+
FileOutputStream(pending).use { output ->
|
|
307
|
+
FileInputStream(source).use { input ->
|
|
308
|
+
val buffer = ByteArray(256 * 1024)
|
|
309
|
+
while (true) {
|
|
310
|
+
if (cancelled.get()) return abandon("cancelled")
|
|
311
|
+
val read = input.read(buffer)
|
|
312
|
+
if (read <= 0) break
|
|
313
|
+
output.write(buffer, 0, read)
|
|
314
|
+
copied += read
|
|
315
|
+
}
|
|
316
|
+
output.flush()
|
|
317
|
+
output.fd.sync()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (copied != source.length()) {
|
|
322
|
+
// A full disk ends a stream without throwing, and the result
|
|
323
|
+
// would play as a truncated video.
|
|
324
|
+
return abandon("the file could not be written completely")
|
|
325
|
+
}
|
|
326
|
+
if (cancelled.get()) return abandon("cancelled")
|
|
327
|
+
|
|
328
|
+
val probe = probe(context, Uri.fromFile(pending))
|
|
329
|
+
if (!probe.optBoolean("readable", false)) {
|
|
330
|
+
return abandon("the saved file could not be read back")
|
|
331
|
+
}
|
|
332
|
+
if (cancelled.get()) return abandon("cancelled")
|
|
333
|
+
|
|
334
|
+
if (!pending.renameTo(target)) return abandon("the saved file could not be published")
|
|
335
|
+
|
|
336
|
+
/*
|
|
337
|
+
The scanner's uri, or the file's.
|
|
338
|
+
|
|
339
|
+
`scanFile` is asynchronous and the caller wants something to hand
|
|
340
|
+
back, so this waits briefly for the row. A scan that is slow is not
|
|
341
|
+
a failed save: the bytes are in place and the scanner will get to
|
|
342
|
+
them, so a timeout still reports success, with the `file:` uri.
|
|
343
|
+
*/
|
|
344
|
+
val scanned = arrayOfNulls<Uri>(1)
|
|
345
|
+
val latch = CountDownLatch(1)
|
|
346
|
+
MediaScannerConnection.scanFile(
|
|
347
|
+
context,
|
|
348
|
+
arrayOf(target.absolutePath),
|
|
349
|
+
arrayOf(MIME),
|
|
350
|
+
) { _, uri ->
|
|
351
|
+
scanned[0] = uri
|
|
352
|
+
latch.countDown()
|
|
353
|
+
}
|
|
354
|
+
runCatching { latch.await(5, TimeUnit.SECONDS) }
|
|
355
|
+
|
|
356
|
+
forget(jobId)
|
|
357
|
+
return out
|
|
358
|
+
.put("ok", true)
|
|
359
|
+
.put("uri", (scanned[0] ?: Uri.fromFile(target)).toString())
|
|
360
|
+
.put("displayName", name)
|
|
361
|
+
.put("location", RELATIVE_PATH)
|
|
362
|
+
.put("bytes", copied)
|
|
363
|
+
.put("legacy", true)
|
|
364
|
+
.put("durationUs", probe.optLong("durationUs"))
|
|
365
|
+
.put("hasVideo", probe.optBoolean("hasVideo"))
|
|
366
|
+
.put("hasAudio", probe.optBoolean("hasAudio"))
|
|
367
|
+
.toString()
|
|
368
|
+
} catch (e: Exception) {
|
|
369
|
+
return abandon(describe(e))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
202
373
|
/** Delete the pending row, then report. Never reached for a published one. */
|
|
203
374
|
private fun cleanup(
|
|
204
375
|
resolver: android.content.ContentResolver,
|
|
@@ -281,7 +452,43 @@ internal object MediaStoreSave {
|
|
|
281
452
|
* platform behaviour rather than state it. A row in this folder, with this
|
|
282
453
|
* name, that somebody else wrote is still not this app's to remove.
|
|
283
454
|
*/
|
|
284
|
-
fun sweepPending(context: Context): String
|
|
455
|
+
fun sweepPending(context: Context): String =
|
|
456
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) sweepLegacy()
|
|
457
|
+
else sweepPendingRows(context)
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The legacy sweep: unfinished files, not rows.
|
|
461
|
+
*
|
|
462
|
+
* Same predicate in spirit as the row sweep — this app's folder, this code's
|
|
463
|
+
* name shape — because this deletes things. A file without the suffix is
|
|
464
|
+
* somebody's saved video and is never touched, including one that a person
|
|
465
|
+
* renamed into the folder themselves.
|
|
466
|
+
*/
|
|
467
|
+
private fun sweepLegacy(): String {
|
|
468
|
+
val out = JSONObject()
|
|
469
|
+
val folder = File(
|
|
470
|
+
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
|
471
|
+
LEGACY_FOLDER,
|
|
472
|
+
)
|
|
473
|
+
var swept = 0
|
|
474
|
+
var inspected = 0
|
|
475
|
+
runCatching {
|
|
476
|
+
for (file in folder.listFiles().orEmpty()) {
|
|
477
|
+
inspected += 1
|
|
478
|
+
if (!file.name.startsWith(".") || !file.name.endsWith(LEGACY_PENDING_SUFFIX)) continue
|
|
479
|
+
if (file.delete()) swept += 1
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return out
|
|
483
|
+
.put("ok", true)
|
|
484
|
+
.put("legacy", true)
|
|
485
|
+
.put("inspected", inspected)
|
|
486
|
+
.put("swept", swept)
|
|
487
|
+
.toString()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@RequiresApi(Build.VERSION_CODES.Q)
|
|
491
|
+
private fun sweepPendingRows(context: Context): String {
|
|
285
492
|
val resolver = context.contentResolver
|
|
286
493
|
val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
287
494
|
var swept = 0
|