@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.
- 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/ExportGate.kt +421 -44
- 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 +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +12 -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/exporter.ts +41 -3
|
@@ -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
|
|
1938
|
-
outputPtsUs: cadence
|
|
1946
|
+
emitsPerInput: cadence?.emitsPerInput,
|
|
1947
|
+
outputPtsUs: cadence?.outputPtsUs,
|
|
1939
1948
|
spanUs: Math.round(shard.plan.frameCount * 1e6 / fps)
|
|
1940
1949
|
};
|
|
1941
1950
|
});
|