@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.
@@ -1039,6 +1039,19 @@ class PictorVideoModule(reactContext: ReactApplicationContext) :
1039
1039
  return org.json.JSONObject().put("ok", true).put("stallAfterFrames", frames.toInt()).toString()
1040
1040
  }
1041
1041
 
1042
+ /**
1043
+ * Gate-only: the smallest failing Media3 export, located one stage at a time.
1044
+ *
1045
+ * Runs on the calling background thread and blocks it — each rung has its
1046
+ * own bounded wait, so the whole ladder is bounded, and a rung that never
1047
+ * completes is reported as a timeout rather than waited on.
1048
+ */
1049
+ @androidx.media3.common.util.UnstableApi
1050
+ override fun exportGateLadder(specJson: String): String {
1051
+ if (!Diagnostics.ENABLED) return Diagnostics.unavailable()
1052
+ return GateLadder.start(reactApplicationContext, specJson)
1053
+ }
1054
+
1042
1055
  /**
1043
1056
  * Arm the gate-only controls.
1044
1057
  *
@@ -0,0 +1,108 @@
1
+ package dev.pictor.video
2
+
3
+ import java.util.concurrent.CountDownLatch
4
+
5
+ /**
6
+ * Which run is in flight, so a cancellation can reach it — and release it.
7
+ *
8
+ * ## The two rules, and what each one cost
9
+ *
10
+ * **Publication and cancellation are one decision.** They used to be two steps
11
+ * apiece: a run checked a `cancelled` flag and then assigned itself to a field,
12
+ * while a cancel set that flag and then read that field. A cancel landing
13
+ * between the check and the assignment read nothing, cancelled nothing and
14
+ * released nothing, while the run it was meant to stop went on to start. The
15
+ * waiter then sat until its timeout — 600 s, on an export a person was watching.
16
+ * Here both sides take the same lock, so a cancel is either seen by the
17
+ * publication or finds the published run. Never neither.
18
+ *
19
+ * **The waiter is released before anything that can block.** Media3's
20
+ * `Transformer.cancel()` does not call the run's listener, so the cancelling
21
+ * side must count the latch down itself — and it must do that *first*. Measured
22
+ * on a wedged shard: the cancel found a live transformer and a live latch, and
23
+ * the waiter still sat for 288 seconds, because `cancel()` blocks joining the
24
+ * threads that are stuck and the countdown was the line after it. Stopping the
25
+ * work is best-effort; ending the wait is not.
26
+ *
27
+ * Generic in the thing being stopped so this holds no Android types and the
28
+ * rules above can be tested for real rather than re-stated in a test's own copy.
29
+ */
30
+ internal class RunRegistry<T> {
31
+
32
+ /** One run's registration: its waiter, and what would stop it. */
33
+ class Handle<T>(val latch: CountDownLatch) {
34
+ @Volatile internal var target: T? = null
35
+ /** Set under the lock. True once a cancel has claimed this run. */
36
+ @Volatile var cancelled: Boolean = false
37
+ internal set
38
+ }
39
+
40
+ private val lock = Any()
41
+ private var active: Handle<T>? = null
42
+
43
+ /**
44
+ * Offer a built run. `false` means a cancel got here first, so do not start
45
+ * it — the caller releases its own latch and reports the run cancelled.
46
+ */
47
+ fun publish(handle: Handle<T>, target: T): Boolean = synchronized(lock) {
48
+ if (handle.cancelled) return false
49
+ handle.target = target
50
+ active = handle
51
+ true
52
+ }
53
+
54
+ /**
55
+ * Claim whatever is in flight, release its waiter, then try to stop it.
56
+ *
57
+ * Returns false when there was nothing published — the caller has already
58
+ * recorded the intent to cancel, and the run that has not published yet
59
+ * will see it and refuse.
60
+ *
61
+ * `stop` runs after the release and its outcome is nobody's business here:
62
+ * a stop that throws, blocks or never happens must not decide whether the
63
+ * export is still waiting.
64
+ */
65
+ fun cancel(stop: (T) -> Unit): Boolean {
66
+ val handle = synchronized(lock) {
67
+ val current = active
68
+ current?.cancelled = true
69
+ current
70
+ }
71
+ if (handle == null) return false
72
+ handle.latch.countDown()
73
+ val target = handle.target
74
+ if (target != null) stop(target)
75
+ return true
76
+ }
77
+
78
+ /**
79
+ * Mark the next publication refused, without needing one yet.
80
+ *
81
+ * A cancel that arrives while a composition is still being built has no run
82
+ * to claim. Recording it on the handle is what makes the publication refuse
83
+ * rather than start something nobody wants.
84
+ */
85
+ fun refuse(handle: Handle<T>) = synchronized(lock) { handle.cancelled = true }
86
+
87
+ /**
88
+ * Forget `handle`, if it is still the one in flight.
89
+ *
90
+ * Scoped, because the caller is a Media3 listener and a listener can arrive
91
+ * **late**. `Transformer.cancel()` does not invoke it, so a wedged run's
92
+ * listener may fire minutes later — by which time a newer export has
93
+ * published itself here. An unscoped clear then forgot the *new* run, and
94
+ * the next cancel found nothing to stop and nothing to release: the very
95
+ * failure this class exists to prevent, reintroduced from the other end.
96
+ *
97
+ * Returns false when the handle was not current, which is the caller's
98
+ * signal that its result belongs to an export nobody is waiting for.
99
+ */
100
+ fun clear(handle: Handle<T>): Boolean = synchronized(lock) {
101
+ if (active !== handle) return false
102
+ active = null
103
+ true
104
+ }
105
+
106
+ /** Forget whatever is in flight. For a job ending, not for a listener. */
107
+ fun clear() = synchronized(lock) { active = null }
108
+ }
@@ -91,6 +91,10 @@ internal class SkiaPassThroughEffect(
91
91
  val overlayTexturesFreed = AtomicLong(0)
92
92
  private val shaderPrograms = AtomicLong(0)
93
93
  private val frames = AtomicLong(0)
94
+
95
+ /** Frames this effect has passed through. Read while a wait has not settled. */
96
+ fun framesSeen(): Long = frames.get()
97
+
94
98
  private val releases = AtomicLong(0)
95
99
  private val timestamps = StringBuilder()
96
100
  private val glErrorCodes = mutableSetOf<Int>()
package/dist/index.d.mts CHANGED
@@ -1284,6 +1284,27 @@ interface Media3ExporterOptions {
1284
1284
  * Native counting is unthrottled regardless; this is only the bridge rate.
1285
1285
  */
1286
1286
  progressPollMs?: number;
1287
+ /**
1288
+ * Which mechanism re-times a clip's **video** when its speed is not 1.
1289
+ *
1290
+ * `'cadence'` (the default, and what has shipped) compiles an exact emit plan
1291
+ * in `resampleClip` and a GL effect drops or repeats frames to match it. The
1292
+ * plan's length is the clip's length by construction, so project time is
1293
+ * exact — and above 1× the decoder still reads every source frame, which is
1294
+ * what produces a burst of output followed immediately by end-of-stream.
1295
+ *
1296
+ * `'media3'` sends no plan, so no cadence effect is built and
1297
+ * `EditedMediaItem.setSpeed` carries the video timing on its own.
1298
+ *
1299
+ * Exposed because the burst is implicated in a vendor encoder that drops its
1300
+ * output end-of-stream at high rates — see
1301
+ * `docs/research/export-speed-stall.md`. It is a comparison switch, not a
1302
+ * recommendation: the two paths select different frames, and which one is
1303
+ * correct is what the comparison is for.
1304
+ *
1305
+ * @experimental
1306
+ */
1307
+ videoSpeedVia?: 'cadence' | 'media3';
1287
1308
  }
1288
1309
  /** Why an export will not start. @experimental */
1289
1310
  declare class VideoExportRejected extends Error {
package/dist/index.d.ts CHANGED
@@ -1284,6 +1284,27 @@ interface Media3ExporterOptions {
1284
1284
  * Native counting is unthrottled regardless; this is only the bridge rate.
1285
1285
  */
1286
1286
  progressPollMs?: number;
1287
+ /**
1288
+ * Which mechanism re-times a clip's **video** when its speed is not 1.
1289
+ *
1290
+ * `'cadence'` (the default, and what has shipped) compiles an exact emit plan
1291
+ * in `resampleClip` and a GL effect drops or repeats frames to match it. The
1292
+ * plan's length is the clip's length by construction, so project time is
1293
+ * exact — and above 1× the decoder still reads every source frame, which is
1294
+ * what produces a burst of output followed immediately by end-of-stream.
1295
+ *
1296
+ * `'media3'` sends no plan, so no cadence effect is built and
1297
+ * `EditedMediaItem.setSpeed` carries the video timing on its own.
1298
+ *
1299
+ * Exposed because the burst is implicated in a vendor encoder that drops its
1300
+ * output end-of-stream at high rates — see
1301
+ * `docs/research/export-speed-stall.md`. It is a comparison switch, not a
1302
+ * recommendation: the two paths select different frames, and which one is
1303
+ * correct is what the comparison is for.
1304
+ *
1305
+ * @experimental
1306
+ */
1307
+ videoSpeedVia?: 'cadence' | 'media3';
1287
1308
  }
1288
1309
  /** Why an export will not start. @experimental */
1289
1310
  declare class VideoExportRejected extends Error {
package/dist/index.js CHANGED
@@ -1910,9 +1910,10 @@ function createMedia3VideoExporter(options) {
1910
1910
  );
1911
1911
  }
1912
1912
  const resources = overlayPlan.resources;
1913
+ const viaMedia3 = options.videoSpeedVia === "media3";
1913
1914
  const shards = plan.map((shard, index) => {
1914
1915
  const clip = playable[index];
1915
- const cadence = cadenceFor(shard.plan.frames);
1916
+ const cadence = viaMedia3 && clip.speed !== 1 ? null : cadenceFor(shard.plan.frames);
1916
1917
  return {
1917
1918
  uri: uris[index],
1918
1919
  startMs: Math.round(clip.sourceIn / 1e3),
@@ -1931,11 +1932,19 @@ function createMedia3VideoExporter(options) {
1931
1932
  What matters is that the *source's* audio never enters this pipeline:
1932
1933
  a per-shard audio track has to be re-encoded to concatenate, which is
1933
1934
  what put a second generation on everything the user hears.
1935
+
1936
+ `ExportGate` notes that `setRemoveAudio` beside a speed change is a
1937
+ combination Media3 warns about, and that was tried as a candidate
1938
+ fix for the high-rate export stall: giving the re-timing the real
1939
+ stream instead of silence. It made no difference to the fault — the
1940
+ encoder stopped at exactly the same place — so the change was
1941
+ reverted rather than kept for a benefit that could not be shown.
1942
+ See `docs/research/export-speed-stall.md`.
1934
1943
  */
1935
1944
  forceAudioTrack: true,
1936
1945
  removeAudio: true,
1937
- emitsPerInput: cadence.emitsPerInput,
1938
- outputPtsUs: cadence.outputPtsUs,
1946
+ emitsPerInput: cadence?.emitsPerInput,
1947
+ outputPtsUs: cadence?.outputPtsUs,
1939
1948
  spanUs: Math.round(shard.plan.frameCount * 1e6 / fps)
1940
1949
  };
1941
1950
  });