@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
|
@@ -111,6 +111,72 @@ internal class ExportGate(private val context: Context) {
|
|
|
111
111
|
|
|
112
112
|
private val main = Handler(Looper.getMainLooper())
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The looper every Transformer is built, started and cancelled on.
|
|
116
|
+
*
|
|
117
|
+
* Not the main one, and that is the whole point. Media3 requires those
|
|
118
|
+
* three to happen on one thread with a Looper, and `Transformer.cancel()`
|
|
119
|
+
* blocks until it has joined its internal threads — on a wedged pipeline,
|
|
120
|
+
* the threads that are stuck. With the main looper as that thread, a
|
|
121
|
+
* cancelled-but-wedged export froze the app permanently: measured, the main
|
|
122
|
+
* looper ran nothing from the moment of the cancel onward, which is why the
|
|
123
|
+
* screen kept showing `Exporting… 98%` while React had already rendered the
|
|
124
|
+
* failure, and why nothing could read the app's accessibility tree.
|
|
125
|
+
*
|
|
126
|
+
* Releasing the export's waiter before calling `cancel()` was necessary and
|
|
127
|
+
* is not sufficient — it frees the *export*, not the thread. Moving the
|
|
128
|
+
* whole Transformer lifecycle here is what keeps a stuck join off the UI.
|
|
129
|
+
*
|
|
130
|
+
* One thread for the life of the gate: the contract is that build, start
|
|
131
|
+
* and cancel agree, not that each run gets its own.
|
|
132
|
+
*/
|
|
133
|
+
/*
|
|
134
|
+
Replaceable, because `cancel()` is allowed not to come back.
|
|
135
|
+
|
|
136
|
+
It joins Media3's internal threads, and on a wedged pipeline those are the
|
|
137
|
+
stuck ones — so the cancel can occupy this looper indefinitely. Every
|
|
138
|
+
later run builds and starts its Transformer here too, which made one
|
|
139
|
+
unrecoverable wedge enough to hang the *next* export before it began.
|
|
140
|
+
|
|
141
|
+
So a cancel that has not returned within [CANCEL_SETTLE_MS] retires the
|
|
142
|
+
thread and the next job gets a fresh one. The retired thread is abandoned,
|
|
143
|
+
not killed: nothing can interrupt a blocked native join, and quitting its
|
|
144
|
+
looper would not either. That leaks one thread per unrecoverable wedge,
|
|
145
|
+
which is the trade — a leaked thread against an export that cannot start
|
|
146
|
+
— and [retiredTransformerThreads] counts them so it is observable rather
|
|
147
|
+
than assumed.
|
|
148
|
+
*/
|
|
149
|
+
@Volatile private var transformerThread = newTransformerThread()
|
|
150
|
+
|
|
151
|
+
@Volatile private var onTransformerThread = Handler(transformerThread.looper)
|
|
152
|
+
|
|
153
|
+
private val retiredTransformerThreads = java.util.concurrent.atomic.AtomicInteger(0)
|
|
154
|
+
|
|
155
|
+
private fun newTransformerThread(): android.os.HandlerThread =
|
|
156
|
+
android.os.HandlerThread("pictor-export-transformer").apply { start() }
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Give up on the current transformer looper and start another.
|
|
160
|
+
*
|
|
161
|
+
* Called only when a cancel posted to it has not answered in time. Idempotent
|
|
162
|
+
* against the thread it was asked to retire: a second caller that finds a
|
|
163
|
+
* different thread already in place does nothing.
|
|
164
|
+
*/
|
|
165
|
+
private fun retireTransformerThread(stuck: android.os.HandlerThread) {
|
|
166
|
+
synchronized(this) {
|
|
167
|
+
if (transformerThread !== stuck) return
|
|
168
|
+
transformerThread = newTransformerThread()
|
|
169
|
+
onTransformerThread = Handler(transformerThread.looper)
|
|
170
|
+
}
|
|
171
|
+
retiredTransformerThreads.incrementAndGet()
|
|
172
|
+
android.util.Log.w(
|
|
173
|
+
"PictorExport",
|
|
174
|
+
"the transformer looper did not answer a cancel within " +
|
|
175
|
+
CANCEL_SETTLE_MS + " ms; abandoned it and started another " +
|
|
176
|
+
"(retired=" + retiredTransformerThreads.get() + ")",
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
114
180
|
/**
|
|
115
181
|
* Files this gate created, so a caller can prove it left none behind.
|
|
116
182
|
*
|
|
@@ -141,17 +207,14 @@ internal class ExportGate(private val context: Context) {
|
|
|
141
207
|
*/
|
|
142
208
|
@Volatile private var currentJob: String? = null
|
|
143
209
|
@Volatile private var cancelled = false
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
came from once the cancel finally reached the Transformer at all.
|
|
153
|
-
*/
|
|
154
|
-
@Volatile private var activeLatch: CountDownLatch? = null
|
|
210
|
+
/**
|
|
211
|
+
* The Transformer run in flight, and the latch whoever is waiting on it holds.
|
|
212
|
+
*
|
|
213
|
+
* The rules it enforces — one atomic decision, and release before stop —
|
|
214
|
+
* and what each one cost are in `RunRegistry`, where they can be tested
|
|
215
|
+
* without an Android runtime.
|
|
216
|
+
*/
|
|
217
|
+
private val runs = RunRegistry<Transformer>()
|
|
155
218
|
|
|
156
219
|
/**
|
|
157
220
|
* Whether to hash every sample to prove the mux copied rather than encoded.
|
|
@@ -219,7 +282,35 @@ internal class ExportGate(private val context: Context) {
|
|
|
219
282
|
fun beginJob(jobId: String): Boolean {
|
|
220
283
|
synchronized(this) {
|
|
221
284
|
if (currentJob != null) return false
|
|
285
|
+
/*
|
|
286
|
+
Media3's own logging, in a diagnostics build.
|
|
287
|
+
|
|
288
|
+
This package's counters stop at the edge of its own effects.
|
|
289
|
+
Whether the encoder drained, whether the muxer finalised and
|
|
290
|
+
whether the listener was ever called are Media3's to say, and
|
|
291
|
+
without this the only way to ask was to guess from a blocked
|
|
292
|
+
stack — which is how "the frame counter reached its total" got
|
|
293
|
+
mistaken for "the shard completed".
|
|
294
|
+
|
|
295
|
+
Compile-time, like every other diagnostic here: a shipped build
|
|
296
|
+
has no path to it. Built with `-PpictorDiagnostics=true`.
|
|
297
|
+
*/
|
|
298
|
+
if (Diagnostics.ENABLED) {
|
|
299
|
+
androidx.media3.common.util.Log.setLogLevel(
|
|
300
|
+
androidx.media3.common.util.Log.LOG_LEVEL_ALL,
|
|
301
|
+
)
|
|
302
|
+
startMainThreadWatch()
|
|
303
|
+
}
|
|
222
304
|
currentJob = jobId
|
|
305
|
+
/*
|
|
306
|
+
At the start as well as the end.
|
|
307
|
+
|
|
308
|
+
A job that never settles never reaches `endJob`, so an end-only
|
|
309
|
+
reading is silent about exactly the runs worth counting — and
|
|
310
|
+
after a wedged export the runs that follow are the ones that do
|
|
311
|
+
not settle.
|
|
312
|
+
*/
|
|
313
|
+
logResources(jobId, "begin")
|
|
223
314
|
cancelled = pendingCancels.remove(jobId)
|
|
224
315
|
resetHeartbeats()
|
|
225
316
|
// Ownership starts here and ends in `endJob`: beats from the job
|
|
@@ -229,12 +320,46 @@ internal class ExportGate(private val context: Context) {
|
|
|
229
320
|
}
|
|
230
321
|
}
|
|
231
322
|
|
|
323
|
+
/**
|
|
324
|
+
* What this job left behind, counted at the moment it gives the job back.
|
|
325
|
+
*
|
|
326
|
+
* A responsive main thread proves the UI survived a wedged export; it says
|
|
327
|
+
* nothing about whether the export's codecs, temporary files and threads
|
|
328
|
+
* went with it. A pipeline that cannot be cancelled cleanly accumulates all
|
|
329
|
+
* three, and the symptom — the *next* export failing to get an encoder — is
|
|
330
|
+
* a long way from the cause.
|
|
331
|
+
*
|
|
332
|
+
* Thread names rather than a total: the interesting ones are Media3's own
|
|
333
|
+
* (`Transformer:Internal`, `ExoPlayer`) and this package's single
|
|
334
|
+
* `pictor-export-transformer`. A count that grows across a stall-and-retry
|
|
335
|
+
* cycle is a leak; a steady one is not.
|
|
336
|
+
*
|
|
337
|
+
* Diagnostics-only, and read-only — it allocates nothing the export needs.
|
|
338
|
+
*/
|
|
339
|
+
private fun logResources(jobId: String?, at: String) {
|
|
340
|
+
if (!Diagnostics.ENABLED) return
|
|
341
|
+
val names = Thread.getAllStackTraces().keys.map { it.name }
|
|
342
|
+
fun count(prefix: String) = names.count { it.startsWith(prefix) }
|
|
343
|
+
val liveTemp = synchronized(created) { created.size }
|
|
344
|
+
android.util.Log.i(
|
|
345
|
+
"PictorResources",
|
|
346
|
+
at + " job=" + (jobId ?: "-") +
|
|
347
|
+
" codecsLive=" + CodecLedger.liveCount() +
|
|
348
|
+
" tempLive=" + liveTemp +
|
|
349
|
+
" threadsTotal=" + names.size +
|
|
350
|
+
" transformerInternal=" + count("Transformer:Internal") +
|
|
351
|
+
" exoPlayer=" + count("ExoPlayer") +
|
|
352
|
+
" pictorExport=" + count("pictor-export"),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
232
356
|
/** Give the job back, whatever happened to it. */
|
|
233
357
|
fun endJob() {
|
|
358
|
+
logResources(currentJob, "end ")
|
|
234
359
|
synchronized(this) {
|
|
235
360
|
currentJob = null
|
|
236
361
|
cancelled = false
|
|
237
|
-
|
|
362
|
+
runs.clear()
|
|
238
363
|
ExportHeartbeat.detach()
|
|
239
364
|
ExportProgress.end()
|
|
240
365
|
}
|
|
@@ -256,16 +381,60 @@ internal class ExportGate(private val context: Context) {
|
|
|
256
381
|
}
|
|
257
382
|
cancelled = true
|
|
258
383
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
384
|
+
/*
|
|
385
|
+
The registry releases the waiter, then hands the Transformer back to
|
|
386
|
+
be stopped. Transformer is main-thread only, so stopping it is a post;
|
|
387
|
+
the release already happened and does not wait for it.
|
|
388
|
+
|
|
389
|
+
Counting down a latch whose run then completes normally is harmless:
|
|
390
|
+
the waiter reports from `result`, `failure` and `cancelledEarly`, and
|
|
391
|
+
`cancelled` is what makes this outcome a cancellation.
|
|
392
|
+
*/
|
|
393
|
+
/*
|
|
394
|
+
Stopped on the main looper, and how long that blocks is measured.
|
|
395
|
+
|
|
396
|
+
Releasing the waiter first stops the *export* waiting on `cancel()`.
|
|
397
|
+
It does nothing about the thread `cancel()` runs on: Media3 requires
|
|
398
|
+
it on the thread the Transformer was built on, which here is main, and
|
|
399
|
+
it blocks until Media3 joins its internal threads. On a wedged
|
|
400
|
+
pipeline those are the stuck ones.
|
|
401
|
+
|
|
402
|
+
Moving it off main would break that contract, so it stays — and the
|
|
403
|
+
duration is logged instead, because "the UI freezes during a cancel"
|
|
404
|
+
is a claim that needs a number rather than an argument. The
|
|
405
|
+
architectural answer, if the number is bad, is to build the
|
|
406
|
+
Transformer on its own looper thread so `cancel()` blocks that one;
|
|
407
|
+
that is a larger change than a cancel path should make on its own.
|
|
408
|
+
*/
|
|
409
|
+
runs.cancel { transformer ->
|
|
410
|
+
val thread = transformerThread
|
|
411
|
+
val returned = CountDownLatch(1)
|
|
412
|
+
onTransformerThread.post {
|
|
413
|
+
val startedNs = System.nanoTime()
|
|
414
|
+
runCatching { transformer.cancel() }
|
|
415
|
+
returned.countDown()
|
|
416
|
+
val ms = (System.nanoTime() - startedNs) / 1_000_000
|
|
417
|
+
if (ms > MAIN_BLOCK_WARN_MS) {
|
|
418
|
+
android.util.Log.w(
|
|
419
|
+
"PictorExportCancel",
|
|
420
|
+
"Transformer.cancel() took " + ms + " ms — off the UI thread",
|
|
421
|
+
)
|
|
422
|
+
}
|
|
268
423
|
}
|
|
424
|
+
/*
|
|
425
|
+
Watched from a third thread, so neither the caller nor the looper
|
|
426
|
+
waits on it.
|
|
427
|
+
|
|
428
|
+
This method is called from the JavaScript thread. Blocking it for
|
|
429
|
+
seconds would be a frozen app, and blocking it for a cancel that
|
|
430
|
+
never returns would be a dead one — so the settle is somebody
|
|
431
|
+
else's job, and all the caller gets is the return.
|
|
432
|
+
*/
|
|
433
|
+
Thread({
|
|
434
|
+
if (!returned.await(CANCEL_SETTLE_MS, TimeUnit.MILLISECONDS)) {
|
|
435
|
+
retireTransformerThread(thread)
|
|
436
|
+
}
|
|
437
|
+
}, "pictor-cancel-settle").apply { isDaemon = true }.start()
|
|
269
438
|
}
|
|
270
439
|
return true
|
|
271
440
|
}
|
|
@@ -382,6 +551,102 @@ internal class ExportGate(private val context: Context) {
|
|
|
382
551
|
* without incrementing the counter is what made the ledger disagree with the
|
|
383
552
|
* disk, so the three steps do not exist separately.
|
|
384
553
|
*/
|
|
554
|
+
/**
|
|
555
|
+
* Wait for a terminal signal, saying what is happening while it does not come.
|
|
556
|
+
*
|
|
557
|
+
* The wait itself is unchanged — the same deadline, the same answer. What is
|
|
558
|
+
* added is that a run which is going to take ten minutes to report a timeout
|
|
559
|
+
* says so on the way, with the counters that separate the two cases anyone
|
|
560
|
+
* cares about: **slow** and **stuck**.
|
|
561
|
+
*
|
|
562
|
+
* That distinction is not available anywhere else. The stall watchdog on the
|
|
563
|
+
* JavaScript side is keyed on *any* report, deliberately — a 0.1x shard
|
|
564
|
+
* spends minutes inside one frame budget and is healthy — so a stage that
|
|
565
|
+
* keeps emitting keeps the watchdog quiet however wrong the emission is.
|
|
566
|
+
* When that happens the only evidence that existed was a progress bar that
|
|
567
|
+
* stopped moving, which is equally consistent with a slow encoder.
|
|
568
|
+
*
|
|
569
|
+
* Deliberately not a second watchdog. It changes no outcome and cancels
|
|
570
|
+
* nothing; it is the log line that makes the difference visible.
|
|
571
|
+
*/
|
|
572
|
+
private fun awaitWithReport(
|
|
573
|
+
latch: CountDownLatch,
|
|
574
|
+
timeoutSeconds: Long,
|
|
575
|
+
stage: String,
|
|
576
|
+
counters: () -> String,
|
|
577
|
+
): BoundedWait.Outcome =
|
|
578
|
+
BoundedWait.await(
|
|
579
|
+
timeoutMs = timeoutSeconds * 1_000L,
|
|
580
|
+
sliceMs = REPORT_EVERY_MS,
|
|
581
|
+
nowMs = { System.nanoTime() / 1_000_000 },
|
|
582
|
+
awaitSlice = { ms -> latch.await(ms, TimeUnit.MILLISECONDS) },
|
|
583
|
+
/*
|
|
584
|
+
The flag, not only the latch.
|
|
585
|
+
|
|
586
|
+
A cancel releases the waiter when it finds a published run. When
|
|
587
|
+
it does not — the composition is still being built, or the run has
|
|
588
|
+
already been cleared — there is no latch to count down, and the
|
|
589
|
+
wait used to sit out its entire deadline with `cancelled` true
|
|
590
|
+
beside it, printing that flag every four seconds. Measured six
|
|
591
|
+
times: a stall reported in seven seconds, settled 600 010 ms
|
|
592
|
+
later.
|
|
593
|
+
*/
|
|
594
|
+
cancelled = { cancelled },
|
|
595
|
+
onSlice = { waitedMs ->
|
|
596
|
+
android.util.Log.i(
|
|
597
|
+
"PictorExportWait",
|
|
598
|
+
"stage=" + stage + " waitedMs=" + waitedMs +
|
|
599
|
+
" cancelled=" + cancelled + " " + counters(),
|
|
600
|
+
)
|
|
601
|
+
},
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Whether the main looper is still running anything, watched from off it.
|
|
606
|
+
*
|
|
607
|
+
* The first version of this posted a runnable and logged how late it ran.
|
|
608
|
+
* That can only report a looper that is *slow*: a looper that is **blocked**
|
|
609
|
+
* runs nothing, so the check never executes and the log stays empty — and
|
|
610
|
+
* empty was read as healthy. It is the same mistake as reading a frame
|
|
611
|
+
* counter that reached its total as a finished shard.
|
|
612
|
+
*
|
|
613
|
+
* So the main thread only stamps a clock, and a plain daemon thread decides
|
|
614
|
+
* whether that stamp has gone stale. Silence from the looper now produces a
|
|
615
|
+
* line instead of the absence of one.
|
|
616
|
+
*
|
|
617
|
+
* Diagnostics-only, and bounded: it outlives the job because the interesting
|
|
618
|
+
* moment is after the export settles, but it does not outlive the run.
|
|
619
|
+
*/
|
|
620
|
+
private fun startMainThreadWatch() {
|
|
621
|
+
val lastTickMs = java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis())
|
|
622
|
+
val untilMs = System.currentTimeMillis() + WATCH_FOR_MS
|
|
623
|
+
val stamp = object : Runnable {
|
|
624
|
+
override fun run() {
|
|
625
|
+
lastTickMs.set(System.currentTimeMillis())
|
|
626
|
+
if (System.currentTimeMillis() < untilMs) main.postDelayed(this, 250)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
main.post(stamp)
|
|
630
|
+
|
|
631
|
+
Thread({
|
|
632
|
+
var reported = false
|
|
633
|
+
while (System.currentTimeMillis() < untilMs) {
|
|
634
|
+
Thread.sleep(250)
|
|
635
|
+
val staleMs = System.currentTimeMillis() - lastTickMs.get()
|
|
636
|
+
if (staleMs > MAIN_BLOCK_WARN_MS && !reported) {
|
|
637
|
+
reported = true
|
|
638
|
+
android.util.Log.w(
|
|
639
|
+
"PictorMainThread",
|
|
640
|
+
"the main looper has run nothing for " + staleMs + " ms",
|
|
641
|
+
)
|
|
642
|
+
} else if (staleMs <= MAIN_BLOCK_WARN_MS && reported) {
|
|
643
|
+
reported = false
|
|
644
|
+
android.util.Log.w("PictorMainThread", "the main looper is answering again")
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}, "pictor-main-watch").apply { isDaemon = true }.start()
|
|
648
|
+
}
|
|
649
|
+
|
|
385
650
|
private fun releaseTemp(file: File) {
|
|
386
651
|
runCatching { file.delete() }
|
|
387
652
|
synchronized(created) { if (created.remove(file)) tempFilesReleased.incrementAndGet() }
|
|
@@ -1354,6 +1619,7 @@ internal class ExportGate(private val context: Context) {
|
|
|
1354
1619
|
|
|
1355
1620
|
val startedNs = System.nanoTime()
|
|
1356
1621
|
val latch = CountDownLatch(1)
|
|
1622
|
+
val handle = RunRegistry.Handle<Transformer>(latch)
|
|
1357
1623
|
var result: ExportResult? = null
|
|
1358
1624
|
var failure: ExportException? = null
|
|
1359
1625
|
var buildFailure: Throwable? = null
|
|
@@ -1361,7 +1627,7 @@ internal class ExportGate(private val context: Context) {
|
|
|
1361
1627
|
// not a failure either: `finishRun` reads this and the cancel flag.
|
|
1362
1628
|
var cancelledEarly = false
|
|
1363
1629
|
|
|
1364
|
-
|
|
1630
|
+
onTransformerThread.post {
|
|
1365
1631
|
try {
|
|
1366
1632
|
val builder = EditedMediaItemSequence.Builder()
|
|
1367
1633
|
builder.experimentalSetForceAudioTrack(true)
|
|
@@ -1412,14 +1678,21 @@ internal class ExportGate(private val context: Context) {
|
|
|
1412
1678
|
val composition = Composition.Builder(builder.build()).build()
|
|
1413
1679
|
val transformer = Transformer.Builder(context)
|
|
1414
1680
|
.addListener(object : Transformer.Listener {
|
|
1681
|
+
/*
|
|
1682
|
+
Scoped to this run's handle, because a listener can arrive
|
|
1683
|
+
late. `Transformer.cancel()` never calls it, so a wedged
|
|
1684
|
+
run's listener may fire long after the export that owned
|
|
1685
|
+
it gave up — by which time a newer one has published
|
|
1686
|
+
itself. An unscoped clear forgot the *new* run, and the
|
|
1687
|
+
next cancel then found nothing to stop and nothing to
|
|
1688
|
+
release.
|
|
1689
|
+
*/
|
|
1415
1690
|
override fun onCompleted(c: Composition, r: ExportResult) {
|
|
1416
|
-
|
|
1417
|
-
activeLatch = null
|
|
1691
|
+
runs.clear(handle)
|
|
1418
1692
|
result = r; latch.countDown()
|
|
1419
1693
|
}
|
|
1420
1694
|
override fun onError(c: Composition, r: ExportResult, e: ExportException) {
|
|
1421
|
-
|
|
1422
|
-
activeLatch = null
|
|
1695
|
+
runs.clear(handle)
|
|
1423
1696
|
result = r; failure = e; latch.countDown()
|
|
1424
1697
|
}
|
|
1425
1698
|
})
|
|
@@ -1434,15 +1707,23 @@ internal class ExportGate(private val context: Context) {
|
|
|
1434
1707
|
cancel issued before any native work still took 4.7 s to settle,
|
|
1435
1708
|
which is the whole of the first shard.
|
|
1436
1709
|
*/
|
|
1437
|
-
|
|
1710
|
+
/*
|
|
1711
|
+
Published under the lock a cancel takes, so the two cannot cross.
|
|
1712
|
+
|
|
1713
|
+
Checking `cancelled` and then assigning was two steps with a gap,
|
|
1714
|
+
and a cancel arriving inside it found no transformer to stop and
|
|
1715
|
+
no latch to release while the run it was meant to stop started
|
|
1716
|
+
anyway.
|
|
1717
|
+
*/
|
|
1718
|
+
if (cancelled) runs.refuse(handle)
|
|
1719
|
+
val started = runs.publish(handle, transformer)
|
|
1720
|
+
if (!started) {
|
|
1438
1721
|
// Not counted here. `finishRun` is the only place a run reaches
|
|
1439
1722
|
// a terminal outcome, so it cannot be counted twice.
|
|
1440
1723
|
cancelledEarly = true
|
|
1441
1724
|
latch.countDown()
|
|
1442
1725
|
return@post
|
|
1443
1726
|
}
|
|
1444
|
-
activeTransformer = transformer
|
|
1445
|
-
activeLatch = latch
|
|
1446
1727
|
transformer.start(composition, output.absolutePath)
|
|
1447
1728
|
} catch (e: Throwable) {
|
|
1448
1729
|
/*
|
|
@@ -1457,9 +1738,13 @@ internal class ExportGate(private val context: Context) {
|
|
|
1457
1738
|
}
|
|
1458
1739
|
}
|
|
1459
1740
|
|
|
1460
|
-
|
|
1741
|
+
// Bounded the same way the shard wait is, and for the same reason: a
|
|
1742
|
+
// cancel that finds no published run has no latch to release, and this
|
|
1743
|
+
// one would otherwise sit out its whole deadline knowing it was over.
|
|
1744
|
+
val waited = awaitWithReport(latch, timeoutSeconds, "mix") { "" }
|
|
1461
1745
|
val elapsedMs = (System.nanoTime() - startedNs) / 1_000_000
|
|
1462
|
-
val ok =
|
|
1746
|
+
val ok = waited == BoundedWait.Outcome.Settled &&
|
|
1747
|
+
failure == null && buildFailure == null && !cancelledEarly
|
|
1463
1748
|
if (!ok) {
|
|
1464
1749
|
releaseTemp(output)
|
|
1465
1750
|
}
|
|
@@ -1471,7 +1756,9 @@ internal class ExportGate(private val context: Context) {
|
|
|
1471
1756
|
error = when {
|
|
1472
1757
|
buildFailure != null -> "audio composition rejected: " +
|
|
1473
1758
|
buildFailure!!.javaClass.simpleName + ": " + buildFailure!!.message
|
|
1474
|
-
|
|
1759
|
+
waited == BoundedWait.Outcome.Cancelled -> "cancelled"
|
|
1760
|
+
waited == BoundedWait.Outcome.TimedOut ->
|
|
1761
|
+
"audio timeline timed out after ${'$'}timeoutSeconds s"
|
|
1475
1762
|
failure != null -> "${'$'}{failure!!.errorCode}: ${'$'}{failure!!.message}"
|
|
1476
1763
|
else -> ""
|
|
1477
1764
|
},
|
|
@@ -1562,6 +1849,7 @@ internal class ExportGate(private val context: Context) {
|
|
|
1562
1849
|
|
|
1563
1850
|
val startedNs = System.nanoTime()
|
|
1564
1851
|
val latch = CountDownLatch(1)
|
|
1852
|
+
val handle = RunRegistry.Handle<Transformer>(latch)
|
|
1565
1853
|
var result: ExportResult? = null
|
|
1566
1854
|
var failure: ExportException? = null
|
|
1567
1855
|
// A shard stopped before its Transformer started is neither a success
|
|
@@ -1576,7 +1864,7 @@ internal class ExportGate(private val context: Context) {
|
|
|
1576
1864
|
null
|
|
1577
1865
|
}
|
|
1578
1866
|
|
|
1579
|
-
|
|
1867
|
+
onTransformerThread.post {
|
|
1580
1868
|
val effects: ImmutableList<Effect> =
|
|
1581
1869
|
if (probe == null) ImmutableList.of() else ImmutableList.of(probe as Effect)
|
|
1582
1870
|
val builder = EditedMediaItemSequence.Builder()
|
|
@@ -1680,7 +1968,30 @@ internal class ExportGate(private val context: Context) {
|
|
|
1680
1968
|
val speedVideo: Effect? = null
|
|
1681
1969
|
if (item != null && item.speed != 1f) {
|
|
1682
1970
|
edited.setSpeed(ConstantSpeed(item.speed))
|
|
1683
|
-
|
|
1971
|
+
/*
|
|
1972
|
+
Not on a track that is thrown away — and that track is
|
|
1973
|
+
what wedges the export.
|
|
1974
|
+
|
|
1975
|
+
Measured with `GateLadder` at a configured 6.122448979591837
|
|
1976
|
+
on a Pixel 7: `speed + cadence + skia` completes in 644 ms
|
|
1977
|
+
with the contract's 29 frames. Add the channel mixer and it
|
|
1978
|
+
still completes, in 776 ms. Add *this* processor instead and
|
|
1979
|
+
the run never finishes — 90 s timeout, the rung's thread
|
|
1980
|
+
still alive afterwards, and a cadence trace reading
|
|
1981
|
+
`emitted=29 endIn=1 endOut=1 glCreated=1 glDeleted=0`:
|
|
1982
|
+
every frame delivered, end-of-stream seen at both ends, and
|
|
1983
|
+
the pipeline never shut down.
|
|
1984
|
+
|
|
1985
|
+
It also bought nothing here. A shard's own audio is removed
|
|
1986
|
+
and a silent track forced in its place, purely so Media3's
|
|
1987
|
+
speed path has something to re-time; the final mux takes
|
|
1988
|
+
only this pipeline's *video*. What a person actually hears
|
|
1989
|
+
is stretched by `AudioMixer.stretch`, which runs Sonic over
|
|
1990
|
+
the real PCM and preserves pitch there. So the correction
|
|
1991
|
+
stays where it is audible and leaves the path where it is
|
|
1992
|
+
silent, discarded, and fatal.
|
|
1993
|
+
*/
|
|
1994
|
+
if (item.speedMode != "resample" && !item.removeAudio) {
|
|
1684
1995
|
/*
|
|
1685
1996
|
Media3 re-times audio by **resampling** it, on both
|
|
1686
1997
|
public paths: `setSpeed` and
|
|
@@ -1781,9 +2092,14 @@ internal class ExportGate(private val context: Context) {
|
|
|
1781
2092
|
}
|
|
1782
2093
|
}
|
|
1783
2094
|
.addListener(object : Transformer.Listener {
|
|
2095
|
+
/*
|
|
2096
|
+
Scoped to this run's handle. See the same guard on the mix
|
|
2097
|
+
sequence: a wedged run's listener can arrive after a newer
|
|
2098
|
+
export has published itself here, and clearing that one
|
|
2099
|
+
would leave it uncancellable.
|
|
2100
|
+
*/
|
|
1784
2101
|
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
1785
|
-
|
|
1786
|
-
activeLatch = null
|
|
2102
|
+
runs.clear(handle)
|
|
1787
2103
|
result = exportResult
|
|
1788
2104
|
latch.countDown()
|
|
1789
2105
|
}
|
|
@@ -1793,8 +2109,7 @@ internal class ExportGate(private val context: Context) {
|
|
|
1793
2109
|
exportResult: ExportResult,
|
|
1794
2110
|
exportException: ExportException,
|
|
1795
2111
|
) {
|
|
1796
|
-
|
|
1797
|
-
activeLatch = null
|
|
2112
|
+
runs.clear(handle)
|
|
1798
2113
|
failure = exportException
|
|
1799
2114
|
latch.countDown()
|
|
1800
2115
|
}
|
|
@@ -1812,13 +2127,14 @@ internal class ExportGate(private val context: Context) {
|
|
|
1812
2127
|
and was caught by the next stage check, which is where the seconds
|
|
1813
2128
|
came from.
|
|
1814
2129
|
*/
|
|
1815
|
-
|
|
2130
|
+
// Published under the lock a cancel takes: see the sibling site above.
|
|
2131
|
+
if (cancelled) runs.refuse(handle)
|
|
2132
|
+
val started = runs.publish(handle, transformer)
|
|
2133
|
+
if (!started) {
|
|
1816
2134
|
cancelledEarly = true
|
|
1817
2135
|
latch.countDown()
|
|
1818
2136
|
return@post
|
|
1819
2137
|
}
|
|
1820
|
-
activeTransformer = transformer
|
|
1821
|
-
activeLatch = latch
|
|
1822
2138
|
runCatching { transformer.start(composition, output.absolutePath) }
|
|
1823
2139
|
.onFailure {
|
|
1824
2140
|
failure = ExportException.createForUnexpected(RuntimeException(it))
|
|
@@ -1826,9 +2142,13 @@ internal class ExportGate(private val context: Context) {
|
|
|
1826
2142
|
}
|
|
1827
2143
|
}
|
|
1828
2144
|
|
|
1829
|
-
val
|
|
2145
|
+
val waited = awaitWithReport(latch, timeoutSeconds, "shard") {
|
|
2146
|
+
"cadence=" + (cadence?.trace() ?: "none") +
|
|
2147
|
+
" skiaFrames=" + (probe?.framesSeen()?.toString() ?: "none")
|
|
2148
|
+
}
|
|
1830
2149
|
val elapsedMs = (System.nanoTime() - startedNs) / 1_000_000
|
|
1831
|
-
val ok =
|
|
2150
|
+
val ok = waited == BoundedWait.Outcome.Settled &&
|
|
2151
|
+
failure == null && result != null && !cancelledEarly
|
|
1832
2152
|
|
|
1833
2153
|
if (!ok) {
|
|
1834
2154
|
// A failed gate leaves no partial file. Transformer writes as it
|
|
@@ -1840,7 +2160,21 @@ internal class ExportGate(private val context: Context) {
|
|
|
1840
2160
|
ok = false,
|
|
1841
2161
|
uri = "",
|
|
1842
2162
|
error = when {
|
|
1843
|
-
|
|
2163
|
+
/*
|
|
2164
|
+
A deadline that expired outranks a cancellation, and the
|
|
2165
|
+
order says so.
|
|
2166
|
+
|
|
2167
|
+
A run that waited out its whole deadline has to report
|
|
2168
|
+
that, even if a cancel was also requested along the way —
|
|
2169
|
+
the two are different failures and only one of them means
|
|
2170
|
+
the platform stopped answering. What changed is that the
|
|
2171
|
+
wait now distinguishes them itself: `TimedOut` is only
|
|
2172
|
+
returned when the time genuinely ran out with no cancel,
|
|
2173
|
+
so a cancelled export no longer arrives here wearing a
|
|
2174
|
+
timeout it did not have.
|
|
2175
|
+
*/
|
|
2176
|
+
waited == BoundedWait.Outcome.TimedOut ->
|
|
2177
|
+
"timeout after ${timeoutSeconds}s"
|
|
1844
2178
|
failure != null -> buildString {
|
|
1845
2179
|
append(failure!!.errorCode).append(": ").append(failure!!.message)
|
|
1846
2180
|
// The message alone says "Video frame processing error"
|
|
@@ -1855,6 +2189,16 @@ internal class ExportGate(private val context: Context) {
|
|
|
1855
2189
|
depth += 1
|
|
1856
2190
|
}
|
|
1857
2191
|
}
|
|
2192
|
+
/*
|
|
2193
|
+
Released by a cancel, with nothing to show for the run.
|
|
2194
|
+
|
|
2195
|
+
Reported as the cancellation it is rather than as "no
|
|
2196
|
+
result": the waiter is now released *before* the
|
|
2197
|
+
Transformer is stopped, so reaching here with no result is
|
|
2198
|
+
the ordinary shape of a cancelled run, not an anomaly.
|
|
2199
|
+
*/
|
|
2200
|
+
waited == BoundedWait.Outcome.Cancelled ||
|
|
2201
|
+
cancelledEarly || cancelled -> "cancelled"
|
|
1858
2202
|
else -> "no result"
|
|
1859
2203
|
},
|
|
1860
2204
|
elapsedMs = elapsedMs,
|
|
@@ -1969,6 +2313,39 @@ internal class ExportGate(private val context: Context) {
|
|
|
1969
2313
|
}
|
|
1970
2314
|
|
|
1971
2315
|
private companion object {
|
|
2316
|
+
/**
|
|
2317
|
+
* How often a wait that has not settled reports itself.
|
|
2318
|
+
*
|
|
2319
|
+
* Long enough that a healthy export logs a handful of lines, short
|
|
2320
|
+
* enough that two consecutive lines show whether the counters moved —
|
|
2321
|
+
* which is the whole question.
|
|
2322
|
+
*/
|
|
2323
|
+
const val REPORT_EVERY_MS = 4_000L
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* A main-thread block worth naming.
|
|
2327
|
+
*
|
|
2328
|
+
* Well past a dropped frame and well under a platform ANR, so the log
|
|
2329
|
+
* carries the cancels that a person would actually feel and stays quiet
|
|
2330
|
+
* for the ones they would not.
|
|
2331
|
+
*/
|
|
2332
|
+
const val MAIN_BLOCK_WARN_MS = 250L
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* How long a posted `Transformer.cancel()` gets before its looper is
|
|
2336
|
+
* abandoned.
|
|
2337
|
+
*
|
|
2338
|
+
* Not a guess at how long a healthy cancel takes — a healthy one is
|
|
2339
|
+
* milliseconds, and the measured unhealthy one never returns at all.
|
|
2340
|
+
* This is how long the *next* export is willing to wait before it stops
|
|
2341
|
+
* caring: three seconds is far past anything legitimate and far short of
|
|
2342
|
+
* a person deciding the app is broken.
|
|
2343
|
+
*/
|
|
2344
|
+
const val CANCEL_SETTLE_MS = 3_000L
|
|
2345
|
+
|
|
2346
|
+
/** How long the looper watch outlives the job that started it. */
|
|
2347
|
+
const val WATCH_FOR_MS = 180_000L
|
|
2348
|
+
|
|
1972
2349
|
const val TEMP_PREFIX = "pictor-gate-"
|
|
1973
2350
|
}
|
|
1974
2351
|
}
|
|
@@ -212,6 +212,11 @@ internal object FrameClock {
|
|
|
212
212
|
/** From `VideoFrameMetadataListener`, before the frame is released. */
|
|
213
213
|
@JvmStatic
|
|
214
214
|
fun offer(ptsUs: Long, scheduledReleaseNs: Long) {
|
|
215
|
+
offerForWindow(ptsUs, scheduledReleaseNs, windowIndex)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Composition frames carry their own window, determined from their PTS. */
|
|
219
|
+
internal fun offerForWindow(ptsUs: Long, scheduledReleaseNs: Long, frameWindowIndex: Int) {
|
|
215
220
|
val now = PlaybackAnchor.nowNs()
|
|
216
221
|
/*
|
|
217
222
|
How far ahead the renderer scheduled this frame.
|
|
@@ -239,7 +244,7 @@ internal object FrameClock {
|
|
|
239
244
|
sample[Field.POSITION_AT_RELEASE_US] = PlaybackAnchor.positionAt(scheduledReleaseNs)
|
|
240
245
|
sample[Field.FLAGS] = stateBits(anchor)
|
|
241
246
|
sample[Field.ANCHOR_AGE_NS] = PlaybackAnchor.ageNs(scheduledReleaseNs)
|
|
242
|
-
sample[Field.WINDOW_INDEX] =
|
|
247
|
+
sample[Field.WINDOW_INDEX] = frameWindowIndex.toLong()
|
|
243
248
|
synchronized(lock) {
|
|
244
249
|
if (metadata.size >= CAPACITY) {
|
|
245
250
|
metadata.removeFirst()
|