@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
|
@@ -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 {
|
|
@@ -2410,6 +2431,10 @@ interface CompositionSpikeItem {
|
|
|
2410
2431
|
/** Clipping bounds in **source** time, which is where speed is applied. */
|
|
2411
2432
|
startUs: number;
|
|
2412
2433
|
endUs: number;
|
|
2434
|
+
/** Full source duration, before trimming or speed. Omit only for legacy callers. */
|
|
2435
|
+
sourceDurationUs?: number;
|
|
2436
|
+
/** Exclusive project-time end from the playlist compiler, after trim and speed. */
|
|
2437
|
+
timelineEndUs?: number;
|
|
2413
2438
|
speed: number;
|
|
2414
2439
|
}
|
|
2415
2440
|
/** @experimental */
|
|
@@ -2496,6 +2521,8 @@ interface CompositionSpikeState {
|
|
|
2496
2521
|
playbackState?: number;
|
|
2497
2522
|
durationUs?: number;
|
|
2498
2523
|
presentedUs?: number | null;
|
|
2524
|
+
/** Playlist window belonging to the latched frame, not the current playhead. */
|
|
2525
|
+
presentedWindowIndex?: number;
|
|
2499
2526
|
/** How old the presented frame was when it was read, so a stalled sample can be excluded. */
|
|
2500
2527
|
presentedAgeNs?: number;
|
|
2501
2528
|
/** How long the paired read itself took. Bounds the pairing 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 {
|
|
@@ -2410,6 +2431,10 @@ interface CompositionSpikeItem {
|
|
|
2410
2431
|
/** Clipping bounds in **source** time, which is where speed is applied. */
|
|
2411
2432
|
startUs: number;
|
|
2412
2433
|
endUs: number;
|
|
2434
|
+
/** Full source duration, before trimming or speed. Omit only for legacy callers. */
|
|
2435
|
+
sourceDurationUs?: number;
|
|
2436
|
+
/** Exclusive project-time end from the playlist compiler, after trim and speed. */
|
|
2437
|
+
timelineEndUs?: number;
|
|
2413
2438
|
speed: number;
|
|
2414
2439
|
}
|
|
2415
2440
|
/** @experimental */
|
|
@@ -2496,6 +2521,8 @@ interface CompositionSpikeState {
|
|
|
2496
2521
|
playbackState?: number;
|
|
2497
2522
|
durationUs?: number;
|
|
2498
2523
|
presentedUs?: number | null;
|
|
2524
|
+
/** Playlist window belonging to the latched frame, not the current playhead. */
|
|
2525
|
+
presentedWindowIndex?: number;
|
|
2499
2526
|
/** How old the presented frame was when it was read, so a stalled sample can be excluded. */
|
|
2500
2527
|
presentedAgeNs?: number;
|
|
2501
2528
|
/** How long the paired read itself took. Bounds the pairing error. */
|
package/dist/index.js
CHANGED
|
@@ -722,6 +722,7 @@ function compositionSpikeSpec(project, mix, resolve) {
|
|
|
722
722
|
const playlist = (0, import_video_core.playlistOf)(project);
|
|
723
723
|
const items = [];
|
|
724
724
|
const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`);
|
|
725
|
+
const clips = new Map(project.clips.map((clip) => [clip.id, clip]));
|
|
725
726
|
for (const item of playlist.items) {
|
|
726
727
|
const uri = resolve(item.source);
|
|
727
728
|
if (uri === null) {
|
|
@@ -732,6 +733,8 @@ function compositionSpikeSpec(project, mix, resolve) {
|
|
|
732
733
|
uri,
|
|
733
734
|
startUs: Number(item.startUs),
|
|
734
735
|
endUs: Number(item.endUs),
|
|
736
|
+
sourceDurationUs: clips.get(item.clipId)?.metadata.duration,
|
|
737
|
+
timelineEndUs: Number(item.timelineEndUs),
|
|
735
738
|
speed: item.speed
|
|
736
739
|
});
|
|
737
740
|
}
|
|
@@ -1910,9 +1913,10 @@ function createMedia3VideoExporter(options) {
|
|
|
1910
1913
|
);
|
|
1911
1914
|
}
|
|
1912
1915
|
const resources = overlayPlan.resources;
|
|
1916
|
+
const viaMedia3 = options.videoSpeedVia === "media3";
|
|
1913
1917
|
const shards = plan.map((shard, index) => {
|
|
1914
1918
|
const clip = playable[index];
|
|
1915
|
-
const cadence = cadenceFor(shard.plan.frames);
|
|
1919
|
+
const cadence = viaMedia3 && clip.speed !== 1 ? null : cadenceFor(shard.plan.frames);
|
|
1916
1920
|
return {
|
|
1917
1921
|
uri: uris[index],
|
|
1918
1922
|
startMs: Math.round(clip.sourceIn / 1e3),
|
|
@@ -1931,11 +1935,19 @@ function createMedia3VideoExporter(options) {
|
|
|
1931
1935
|
What matters is that the *source's* audio never enters this pipeline:
|
|
1932
1936
|
a per-shard audio track has to be re-encoded to concatenate, which is
|
|
1933
1937
|
what put a second generation on everything the user hears.
|
|
1938
|
+
|
|
1939
|
+
`ExportGate` notes that `setRemoveAudio` beside a speed change is a
|
|
1940
|
+
combination Media3 warns about, and that was tried as a candidate
|
|
1941
|
+
fix for the high-rate export stall: giving the re-timing the real
|
|
1942
|
+
stream instead of silence. It made no difference to the fault — the
|
|
1943
|
+
encoder stopped at exactly the same place — so the change was
|
|
1944
|
+
reverted rather than kept for a benefit that could not be shown.
|
|
1945
|
+
See `docs/research/export-speed-stall.md`.
|
|
1934
1946
|
*/
|
|
1935
1947
|
forceAudioTrack: true,
|
|
1936
1948
|
removeAudio: true,
|
|
1937
|
-
emitsPerInput: cadence
|
|
1938
|
-
outputPtsUs: cadence
|
|
1949
|
+
emitsPerInput: cadence?.emitsPerInput,
|
|
1950
|
+
outputPtsUs: cadence?.outputPtsUs,
|
|
1939
1951
|
spanUs: Math.round(shard.plan.frameCount * 1e6 / fps)
|
|
1940
1952
|
};
|
|
1941
1953
|
});
|