@pictor-sdk/video-android 0.1.0 → 0.1.3

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.
@@ -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
- @Volatile private var activeTransformer: Transformer? = null
145
- /*
146
- The run's latch, so a cancellation can end the wait.
147
-
148
- `Transformer.cancel()` does not call the listener — traced: cancel entered
149
- and exited on the main thread in 47 ms, and no `onCompleted` or `onError`
150
- ever followed. The run's latch was therefore never counted down and the
151
- export sat on `latch.await` until its timeout, which is where the seconds
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
- activeTransformer = null
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
- val running = activeTransformer
260
- if (running != null) {
261
- // Transformer is main-thread only; cancelling from the export's own
262
- // worker would be the same crash this gate wraps elsewhere.
263
- main.post {
264
- runCatching { running.cancel() }
265
- // `cancel()` is silent, so the waiter is released here. Without
266
- // this the run waits out its whole timeout after being stopped.
267
- activeLatch?.countDown()
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
- main.post {
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
- activeTransformer = null
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
- activeTransformer = null
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
- if (cancelled) {
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
- val finished = latch.await(timeoutSeconds, TimeUnit.SECONDS)
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 = finished && failure == null && buildFailure == null && !cancelledEarly
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
- !finished -> "audio timeline timed out after ${'$'}timeoutSeconds s"
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
- main.post {
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
- if (item.speedMode != "resample") {
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
- activeTransformer = null
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
- activeTransformer = null
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
- if (cancelled) {
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 finished = latch.await(timeoutSeconds, TimeUnit.SECONDS)
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 = finished && failure == null && result != null && !cancelledEarly
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
- !finished -> "timeout after ${timeoutSeconds}s"
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
  }