@pictor-sdk/video-android 0.1.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
package/src/media3.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { type PlaylistResult } from '@pictor-sdk/video-core'
|
|
2
|
+
import { requireNative } from './player'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Playback backed by Media3's ExoPlayer.
|
|
6
|
+
*
|
|
7
|
+
* Media3 owns decoding, buffering, audio focus, synchronisation, seeking, speed
|
|
8
|
+
* and lifecycle. Pictor owns the document, the timeline and the Skia
|
|
9
|
+
* composition. This is the whole of the seam between them.
|
|
10
|
+
*
|
|
11
|
+
* **No Media3 or Android type appears here.** Positions are microseconds
|
|
12
|
+
* because that is Pictor's unit; the adapter converts.
|
|
13
|
+
*
|
|
14
|
+
* Video is rendered into the persistent external texture, so the renderer holds
|
|
15
|
+
* one `SkImage` for a whole session and nothing is imported per frame.
|
|
16
|
+
*
|
|
17
|
+
* @experimental
|
|
18
|
+
*/
|
|
19
|
+
export interface Media3State {
|
|
20
|
+
positionUs: number
|
|
21
|
+
durationUs: number
|
|
22
|
+
playing: boolean
|
|
23
|
+
buffering: boolean
|
|
24
|
+
ready: boolean
|
|
25
|
+
ended: boolean
|
|
26
|
+
rate: number
|
|
27
|
+
width: number
|
|
28
|
+
height: number
|
|
29
|
+
released: boolean
|
|
30
|
+
error: string
|
|
31
|
+
/** Bumped on seek, source change and release; pairs frames with a clip. */
|
|
32
|
+
generation: number
|
|
33
|
+
/** Frame metadata awaiting a renderer. Bounded. */
|
|
34
|
+
queueDepth: number
|
|
35
|
+
/** Metadata dropped because the queue was full. Should stay zero. */
|
|
36
|
+
overflowed: number
|
|
37
|
+
/** Frames consumed with no sample to pair. Expected briefly after a seek. */
|
|
38
|
+
unpaired: number
|
|
39
|
+
/** Samples discarded because their generation was superseded. */
|
|
40
|
+
stale: number
|
|
41
|
+
queueHighWater: number
|
|
42
|
+
/** Live threads in the process. Diagnostics for leak attribution. */
|
|
43
|
+
threads: number
|
|
44
|
+
/** Allocated native heap, for the safety cutoff. */
|
|
45
|
+
nativeHeapBytes: number
|
|
46
|
+
/** The anchor's own position, before extrapolation. Diagnostics. */
|
|
47
|
+
anchorPositionUs: number
|
|
48
|
+
/** Frames the buffer queue dropped because the renderer was behind. */
|
|
49
|
+
coalesced: number
|
|
50
|
+
/** How coarse `Player.currentPosition` is, measured rather than assumed. */
|
|
51
|
+
anchorPublications: number
|
|
52
|
+
anchorChanges: number
|
|
53
|
+
anchorMaxStepUs: number
|
|
54
|
+
anchorMeanStepUs: number
|
|
55
|
+
anchorMaxStaleNs: number
|
|
56
|
+
/** Which playlist window is playing, and how many there are. */
|
|
57
|
+
windowIndex: number
|
|
58
|
+
windowCount: number
|
|
59
|
+
/** Offset inside the current window, in microseconds. */
|
|
60
|
+
windowPositionUs: number
|
|
61
|
+
/** Window transitions so far — seams crossed. Diagnostics. */
|
|
62
|
+
transitions: number
|
|
63
|
+
/**
|
|
64
|
+
* Frames whose latch failed and was retried on the next tick.
|
|
65
|
+
*
|
|
66
|
+
* `updateTexImage` returns quietly when Skia's GL context is not current on
|
|
67
|
+
* the calling thread, so this counts the retries rather than letting the
|
|
68
|
+
* frames disappear. A handful around startup and playlist changes is normal;
|
|
69
|
+
* a rate that tracks the frame rate is not.
|
|
70
|
+
*/
|
|
71
|
+
missedLatches: number
|
|
72
|
+
/**
|
|
73
|
+
* What the single latch owner did, per frame.
|
|
74
|
+
*
|
|
75
|
+
* `skipped` counts ticks refused because the surface was retired or replaced
|
|
76
|
+
* — the invalid call, prevented rather than caught. `failed` must stay
|
|
77
|
+
* visible: a latch that quietly does nothing looks exactly like a paused
|
|
78
|
+
* video, which is how an earlier defect hid for a whole session.
|
|
79
|
+
*/
|
|
80
|
+
updatesAttempted: number
|
|
81
|
+
updatesSucceeded: number
|
|
82
|
+
updatesSkipped: number
|
|
83
|
+
updatesFailed: number
|
|
84
|
+
/**
|
|
85
|
+
* Times a requested rate exceeded the range and had to be clamped.
|
|
86
|
+
*
|
|
87
|
+
* Should stay zero: the UI only offers rates that compose within range. A
|
|
88
|
+
* non-zero value means the picture and the timeline are counting differently.
|
|
89
|
+
*/
|
|
90
|
+
rateConstrained: number
|
|
91
|
+
/** The rate actually in force: clip speed times preview rate, clamped. */
|
|
92
|
+
effectiveRate: number
|
|
93
|
+
/**
|
|
94
|
+
* The rate asked for, before clamping.
|
|
95
|
+
*
|
|
96
|
+
* Beside `effectiveRate` this says whether a ceiling was *selected* or merely
|
|
97
|
+
* reached: 10 requested and 10 effective is a legitimate extreme, while 25
|
|
98
|
+
* requested and 10 effective is a refusal.
|
|
99
|
+
*/
|
|
100
|
+
requestedRate: number
|
|
101
|
+
/**
|
|
102
|
+
* Window indices that had no speed. Should stay zero.
|
|
103
|
+
*
|
|
104
|
+
* Non-zero means the playlist and the window index were read at different
|
|
105
|
+
* moments — the shape that left a cleared playlist running at the rate of the
|
|
106
|
+
* playlist before it.
|
|
107
|
+
*/
|
|
108
|
+
staleWindowSpeeds: number
|
|
109
|
+
/** How many per-window speeds the player holds. Zero when idle. */
|
|
110
|
+
windowSpeedCount: number
|
|
111
|
+
/**
|
|
112
|
+
* The two halves of frame delivery, counted separately.
|
|
113
|
+
*
|
|
114
|
+
* `framesOffered` is Media3 telling us a frame is about to be released;
|
|
115
|
+
* `framesAvailable` is the SurfaceTexture saying one arrived. If the first
|
|
116
|
+
* climbs while the second does not, the decoder is running but its output is
|
|
117
|
+
* not reaching our surface — which is a completely different fault from the
|
|
118
|
+
* renderer failing to pull.
|
|
119
|
+
*/
|
|
120
|
+
framesOffered: number
|
|
121
|
+
framesAvailable: number
|
|
122
|
+
/**
|
|
123
|
+
* Frames waiting to be latched, right now.
|
|
124
|
+
*
|
|
125
|
+
* A level rather than an edge, so it survives the consumer being disarmed.
|
|
126
|
+
* Usually 0 or 1; larger after a background, and it must fall to 0 within a
|
|
127
|
+
* tick of the consumer re-arming.
|
|
128
|
+
*/
|
|
129
|
+
pendingDepth: number
|
|
130
|
+
/** Availability announced for a generation already retired. Should stay 0. */
|
|
131
|
+
framesRejected: number
|
|
132
|
+
/**
|
|
133
|
+
* The generation the pending counters are keeping for.
|
|
134
|
+
*
|
|
135
|
+
* Must equal the consumer's generation. A frame pending for a different one
|
|
136
|
+
* is a frame nobody will ever take.
|
|
137
|
+
*/
|
|
138
|
+
pendingGeneration: number
|
|
139
|
+
/** Producer identity, for proving which object a consumer is holding. */
|
|
140
|
+
controllerId: number
|
|
141
|
+
pendingId: number
|
|
142
|
+
orphaned: number
|
|
143
|
+
/** `mime WxH rotN @fps` for the active item. Empty when there is no video. */
|
|
144
|
+
videoFormat: string
|
|
145
|
+
/** The decoder Media3 selected, e.g. `c2.exynos.h264.decoder`. */
|
|
146
|
+
decoderName: string
|
|
147
|
+
/**
|
|
148
|
+
* What the decoder was configured for, and what it produced.
|
|
149
|
+
*
|
|
150
|
+
* Read from the player's own events rather than inferred from the device: a
|
|
151
|
+
* mixed-resolution seam is the one moment input and output can disagree, and
|
|
152
|
+
* naming the decoder from the model instead of asking is how an attribution
|
|
153
|
+
* survives without ever being checked.
|
|
154
|
+
*/
|
|
155
|
+
inputFormats: number
|
|
156
|
+
inputWidth: number
|
|
157
|
+
inputHeight: number
|
|
158
|
+
inputRotation: number
|
|
159
|
+
/** Media3's decoder-reuse verdict across the last format change; -1 if none. */
|
|
160
|
+
decoderReuse: number
|
|
161
|
+
outputSizes: number
|
|
162
|
+
outputWidth: number
|
|
163
|
+
outputHeight: number
|
|
164
|
+
/** Increments on each decoder init — makes re-attachment visible. */
|
|
165
|
+
surfaceGeneration: number
|
|
166
|
+
/**
|
|
167
|
+
* Seek bookkeeping for the cross-window freeze soak.
|
|
168
|
+
*
|
|
169
|
+
* `seeksRequested` counts calls; `seeksAccepted` counts those the player's
|
|
170
|
+
* own thread acted on. A gap between them, or a `pendingSeek` that stays
|
|
171
|
+
* true, is a seek that never reached the player — which is what the observed
|
|
172
|
+
* freeze would look like from this side.
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Surface attachment bookkeeping, for diagnostics only.
|
|
176
|
+
*
|
|
177
|
+
* The adapter attaches automatically the instant a surface becomes live, so a
|
|
178
|
+
* host never calls anything. These exist so the invariant can be *checked*:
|
|
179
|
+
* `attachedSurfaceGeneration` trailing `liveSurfaceGeneration` means the
|
|
180
|
+
* player is rendering into a surface nobody is watching.
|
|
181
|
+
*/
|
|
182
|
+
surfaceAttachments: number
|
|
183
|
+
attachedSurfaceGeneration: number
|
|
184
|
+
liveSurfaceGeneration: number
|
|
185
|
+
seeksRequested: number
|
|
186
|
+
seeksAccepted: number
|
|
187
|
+
pendingSeek: boolean
|
|
188
|
+
/** Media3's own playback state constant. 1 idle, 2 buffering, 3 ready, 4 ended. */
|
|
189
|
+
playbackState: number
|
|
190
|
+
/** PTS of the last frame the renderer actually paired, or -1. */
|
|
191
|
+
lastPairedPtsUs: number
|
|
192
|
+
/**
|
|
193
|
+
* Source failures reported by the player, not inferred from the state.
|
|
194
|
+
*
|
|
195
|
+
* A failed source otherwise looks exactly like an idle one — nothing playing,
|
|
196
|
+
* nothing buffering, no duration — so the count is what distinguishes "never
|
|
197
|
+
* started" from "started and broke".
|
|
198
|
+
*/
|
|
199
|
+
errorCount: number
|
|
200
|
+
/** Media3's error code, and its name plus message. Empty when healthy. */
|
|
201
|
+
errorCode: number
|
|
202
|
+
errorMessage: string
|
|
203
|
+
/** Whether Media3 classifies the failure as worth retrying. */
|
|
204
|
+
errorRecoverable: boolean
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const EMPTY: Media3State = {
|
|
208
|
+
positionUs: 0,
|
|
209
|
+
durationUs: 0,
|
|
210
|
+
playing: false,
|
|
211
|
+
buffering: false,
|
|
212
|
+
ready: false,
|
|
213
|
+
ended: false,
|
|
214
|
+
rate: 1,
|
|
215
|
+
width: 0,
|
|
216
|
+
height: 0,
|
|
217
|
+
released: true,
|
|
218
|
+
error: '',
|
|
219
|
+
generation: 0,
|
|
220
|
+
queueDepth: 0,
|
|
221
|
+
overflowed: 0,
|
|
222
|
+
unpaired: 0,
|
|
223
|
+
stale: 0,
|
|
224
|
+
queueHighWater: 0,
|
|
225
|
+
threads: 0,
|
|
226
|
+
nativeHeapBytes: 0,
|
|
227
|
+
anchorPositionUs: 0,
|
|
228
|
+
coalesced: 0,
|
|
229
|
+
anchorPublications: 0,
|
|
230
|
+
anchorChanges: 0,
|
|
231
|
+
anchorMaxStepUs: 0,
|
|
232
|
+
anchorMeanStepUs: 0,
|
|
233
|
+
anchorMaxStaleNs: 0,
|
|
234
|
+
windowIndex: 0,
|
|
235
|
+
windowCount: 0,
|
|
236
|
+
windowPositionUs: 0,
|
|
237
|
+
transitions: 0,
|
|
238
|
+
missedLatches: 0,
|
|
239
|
+
updatesAttempted: 0,
|
|
240
|
+
updatesSucceeded: 0,
|
|
241
|
+
updatesSkipped: 0,
|
|
242
|
+
updatesFailed: 0,
|
|
243
|
+
rateConstrained: 0,
|
|
244
|
+
effectiveRate: 1,
|
|
245
|
+
requestedRate: 1,
|
|
246
|
+
staleWindowSpeeds: 0,
|
|
247
|
+
windowSpeedCount: 0,
|
|
248
|
+
framesOffered: 0,
|
|
249
|
+
framesAvailable: 0,
|
|
250
|
+
pendingDepth: 0,
|
|
251
|
+
framesRejected: 0,
|
|
252
|
+
pendingGeneration: 0,
|
|
253
|
+
controllerId: 0,
|
|
254
|
+
pendingId: 0,
|
|
255
|
+
orphaned: 0,
|
|
256
|
+
videoFormat: '',
|
|
257
|
+
decoderName: '',
|
|
258
|
+
inputFormats: 0,
|
|
259
|
+
inputWidth: 0,
|
|
260
|
+
inputHeight: 0,
|
|
261
|
+
inputRotation: 0,
|
|
262
|
+
decoderReuse: -1,
|
|
263
|
+
outputSizes: 0,
|
|
264
|
+
outputWidth: 0,
|
|
265
|
+
outputHeight: 0,
|
|
266
|
+
surfaceGeneration: 0,
|
|
267
|
+
surfaceAttachments: 0,
|
|
268
|
+
attachedSurfaceGeneration: 0,
|
|
269
|
+
liveSurfaceGeneration: 0,
|
|
270
|
+
seeksRequested: 0,
|
|
271
|
+
seeksAccepted: 0,
|
|
272
|
+
pendingSeek: false,
|
|
273
|
+
playbackState: 0,
|
|
274
|
+
lastPairedPtsUs: -1,
|
|
275
|
+
errorCount: 0,
|
|
276
|
+
errorCode: 0,
|
|
277
|
+
errorMessage: '',
|
|
278
|
+
errorRecoverable: false,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @experimental */
|
|
282
|
+
export const Media3 = {
|
|
283
|
+
/**
|
|
284
|
+
* Open a local clip and render it into the presentation surface.
|
|
285
|
+
*
|
|
286
|
+
* The surface must already exist: it is created on the UI runtime, where
|
|
287
|
+
* Skia's GL context lives, and preparing without one is an error rather than
|
|
288
|
+
* a silently black picture.
|
|
289
|
+
*/
|
|
290
|
+
prepare(uri: string): boolean {
|
|
291
|
+
return requireNative().media3Prepare(uri)
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/** Diagnostics: a player with no video output, for heap attribution. */
|
|
295
|
+
prepareNoSurface(uri: string): boolean {
|
|
296
|
+
return requireNative().media3PrepareNoSurface(uri)
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/** Diagnostics: reuse the existing player for another source. */
|
|
300
|
+
rebind(uri: string): boolean {
|
|
301
|
+
return requireNative().media3Rebind(uri)
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/** Diagnostics: end the source, keep the player. */
|
|
305
|
+
stopKeepingPlayer(): void {
|
|
306
|
+
requireNative().media3StopKeepingPlayer()
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Replace the playlist from a project's playable windows.
|
|
311
|
+
*
|
|
312
|
+
* Takes the `PlaylistResult` straight from `@pictor-sdk/video-core` so the
|
|
313
|
+
* project → window mapping has exactly one definition, tested there as a
|
|
314
|
+
* round trip.
|
|
315
|
+
* The sources must already be URI strings; this adapter does not interpret a
|
|
316
|
+
* host's media descriptors, and a non-URI source is a host normalisation bug
|
|
317
|
+
* rather than something to guess at.
|
|
318
|
+
*
|
|
319
|
+
* The OES texture and the `SkImage` survive this. Changing the playlist is
|
|
320
|
+
* not a reason to rebuild what the renderer holds.
|
|
321
|
+
*/
|
|
322
|
+
setPlaylist(
|
|
323
|
+
playlist: PlaylistResult,
|
|
324
|
+
start: { windowIndex: number; positionUs: number } = { windowIndex: 0, positionUs: 0 },
|
|
325
|
+
options: { allowDivergence?: boolean } = {},
|
|
326
|
+
): boolean {
|
|
327
|
+
// Refuse a playlist that would not play what the timeline shows.
|
|
328
|
+
//
|
|
329
|
+
// An unsupported clip keeps its span on the timeline while contributing
|
|
330
|
+
// nothing to playback, so the two clocks diverge by exactly its duration
|
|
331
|
+
// and every seam after it lands wrong. That is far worse than an error: it
|
|
332
|
+
// looks like a working editor that is slightly out of sync. A host that
|
|
333
|
+
// genuinely wants the playable remainder must ask for it.
|
|
334
|
+
if (playlist.unsupported.length > 0 && options.allowDivergence !== true) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
'@pictor-sdk/video-android: refusing to build a playlist that would diverge from the ' +
|
|
337
|
+
`timeline. ${playlist.unsupported.length} clip(s) cannot play:\n` +
|
|
338
|
+
playlist.unsupported.map((u) => ` · ${u.message}`).join('\n') +
|
|
339
|
+
'\nPass { allowDivergence: true } to play the remainder anyway.',
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
const uris: string[] = []
|
|
343
|
+
const bounds: number[] = []
|
|
344
|
+
for (const item of playlist.items) {
|
|
345
|
+
uris.push(uriOf(item.source))
|
|
346
|
+
bounds.push(item.startUs, item.endUs, Math.round(item.speed * 1000), item.muted ? 1 : 0)
|
|
347
|
+
}
|
|
348
|
+
return requireNative().media3SetPlaylist(uris, bounds, start.windowIndex, start.positionUs)
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/** Seek in the player's coordinates. Pair with `projectTimeToWindow`. */
|
|
352
|
+
seekToWindow(windowIndex: number, positionUs: number): void {
|
|
353
|
+
requireNative().media3SeekToWindow(windowIndex, positionUs)
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
play(): void {
|
|
357
|
+
requireNative().media3Play()
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
pause(): void {
|
|
361
|
+
requireNative().media3Pause()
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
/** Microseconds, like every other time in Pictor. */
|
|
365
|
+
seek(positionUs: number): void {
|
|
366
|
+
requireNative().media3Seek(positionUs)
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
setRate(rate: number): void {
|
|
370
|
+
requireNative().media3SetRate(rate)
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Try the current source again after a failure.
|
|
375
|
+
*
|
|
376
|
+
* This is Media3's own recovery path: it re-resolves the source and resumes
|
|
377
|
+
* from the current position. Worth calling when `errorRecoverable` is true —
|
|
378
|
+
* a network source that went away and came back — and pointless otherwise.
|
|
379
|
+
*/
|
|
380
|
+
retry(): void {
|
|
381
|
+
requireNative().media3Retry()
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
release(): void {
|
|
385
|
+
requireNative().media3Release()
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/** A snapshot for throttled reads. Never call this per frame. */
|
|
389
|
+
state(): Media3State {
|
|
390
|
+
const raw = requireNative().media3State() as Partial<Media3State> | null
|
|
391
|
+
return raw ? { ...EMPTY, ...raw } : EMPTY
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A source as a URI string, or a readable failure.
|
|
397
|
+
*
|
|
398
|
+
* `MediaSource` is deliberately open — the timeline stores whatever descriptor
|
|
399
|
+
* a host uses — so the adapter is where it has to become something Android can
|
|
400
|
+
* open. Failing loudly here is better than handing `undefined` to `Uri.parse`
|
|
401
|
+
* and getting a black picture with no explanation.
|
|
402
|
+
*/
|
|
403
|
+
function uriOf(source: unknown): string {
|
|
404
|
+
if (typeof source === 'string') return source
|
|
405
|
+
if (source && typeof source === 'object' && 'uri' in source) {
|
|
406
|
+
const uri = (source as { uri?: unknown }).uri
|
|
407
|
+
if (typeof uri === 'string') return uri
|
|
408
|
+
}
|
|
409
|
+
throw new Error(
|
|
410
|
+
`@pictor-sdk/video-android: a clip's source is not a URI. Normalise host media to { kind: 'uri', uri } before opening a project. Received: ${JSON.stringify(source)}`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { MediaSaver, SavedMedia } from '@pictor-sdk/video-core'
|
|
2
|
+
import NativePictorVideoModule from './NativePictorVideo'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Saving a finished export into Android's video collection.
|
|
6
|
+
*
|
|
7
|
+
* A thin boundary. The insert-pending / stream / verify / publish sequence is
|
|
8
|
+
* native, because it is where the file descriptors and the cancellation live;
|
|
9
|
+
* this turns one JSON result into either a {@link SavedMedia} or a typed error,
|
|
10
|
+
* and gives every save an id so a cancel reaches the right one.
|
|
11
|
+
*
|
|
12
|
+
* ## What a failure guarantees
|
|
13
|
+
*
|
|
14
|
+
* Nothing partial. Every unsuccessful native path — a short write, a full disk,
|
|
15
|
+
* a cancellation, an unreadable result — deletes the pending row before
|
|
16
|
+
* returning, and a pending row was never visible to the gallery in the first
|
|
17
|
+
* place. So a failed save leaves the collection exactly as it was.
|
|
18
|
+
*
|
|
19
|
+
* ## What it never does
|
|
20
|
+
*
|
|
21
|
+
* Delete a published item. Releasing the export's temporary is a separate act
|
|
22
|
+
* on a separate file, and a saved video belongs to the user from the moment it
|
|
23
|
+
* is published.
|
|
24
|
+
*
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Why a save did not happen. @experimental */
|
|
29
|
+
export class MediaSaveFailed extends Error {
|
|
30
|
+
/** Safe to display: never a path, a stack, or a platform message. */
|
|
31
|
+
readonly reason: string
|
|
32
|
+
constructor(reason: string) {
|
|
33
|
+
super(`save failed: ${reason}`)
|
|
34
|
+
this.name = 'MediaSaveFailed'
|
|
35
|
+
this.reason = reason
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A save the user asked to stop. @experimental */
|
|
40
|
+
export class MediaSaveCancelled extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super('save cancelled')
|
|
43
|
+
this.name = 'MediaSaveCancelled'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Offer a saved video to the system share sheet.
|
|
49
|
+
*
|
|
50
|
+
* Only after a successful save, and only ever the durable `content:` uri. The
|
|
51
|
+
* cache path the export produced is app-private: a receiver could not open it,
|
|
52
|
+
* and handing one out is the mistake `FileUriExposedException` exists to catch.
|
|
53
|
+
*
|
|
54
|
+
* Sharing changes nothing. The same row is offered every time — no copy, no
|
|
55
|
+
* rename, no re-publish, and nothing released.
|
|
56
|
+
*
|
|
57
|
+
* Returns `false` when the device has nothing that accepts a video, which is an
|
|
58
|
+
* outcome rather than a failure: the saved video is untouched either way.
|
|
59
|
+
*
|
|
60
|
+
* @experimental
|
|
61
|
+
*/
|
|
62
|
+
export function shareSavedMedia(uri: string): { ok: boolean; noTargets: boolean; reason: string } {
|
|
63
|
+
if (!NativePictorVideoModule) {
|
|
64
|
+
return { ok: false, noTargets: false, reason: 'sharing is not available' }
|
|
65
|
+
}
|
|
66
|
+
if (!uri.startsWith('content://')) {
|
|
67
|
+
/*
|
|
68
|
+
Refused rather than attempted.
|
|
69
|
+
|
|
70
|
+
The only uri worth sharing is the published one. A cache path reaching
|
|
71
|
+
here would mean the save had not happened, and offering it would either
|
|
72
|
+
throw on a modern target or hand a receiver something it cannot read.
|
|
73
|
+
*/
|
|
74
|
+
return { ok: false, noTargets: false, reason: 'only a saved video can be shared' }
|
|
75
|
+
}
|
|
76
|
+
const result = JSON.parse(NativePictorVideoModule.mediaShare(uri)) as {
|
|
77
|
+
ok?: boolean
|
|
78
|
+
noTargets?: boolean
|
|
79
|
+
error?: string
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ok: result.ok === true,
|
|
83
|
+
noTargets: result.noTargets === true,
|
|
84
|
+
reason: result.error ?? '',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let counter = 0
|
|
89
|
+
|
|
90
|
+
/** @experimental */
|
|
91
|
+
export function createMediaStoreSaver(): MediaSaver {
|
|
92
|
+
/*
|
|
93
|
+
The job whose cancel is live.
|
|
94
|
+
|
|
95
|
+
Held so a cancel arriving after one save settled cannot stop the next: the
|
|
96
|
+
id is checked natively, and clearing it here means a stray press reaches
|
|
97
|
+
nothing at all.
|
|
98
|
+
*/
|
|
99
|
+
let active: string | null = null
|
|
100
|
+
|
|
101
|
+
const native = () => {
|
|
102
|
+
if (!NativePictorVideoModule) {
|
|
103
|
+
throw new MediaSaveFailed('the Pictor video native module is not installed')
|
|
104
|
+
}
|
|
105
|
+
return NativePictorVideoModule
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
async save(sourceUri: string): Promise<SavedMedia> {
|
|
110
|
+
if (active !== null) {
|
|
111
|
+
// Refused rather than queued: two saves of the same export would insert
|
|
112
|
+
// two rows, and the user asked for one video.
|
|
113
|
+
throw new MediaSaveFailed('a save is already running')
|
|
114
|
+
}
|
|
115
|
+
counter += 1
|
|
116
|
+
const id = `save_${counter.toString(36)}`
|
|
117
|
+
active = id
|
|
118
|
+
let outcome: {
|
|
119
|
+
ok: boolean
|
|
120
|
+
uri?: string
|
|
121
|
+
displayName?: string
|
|
122
|
+
location?: string
|
|
123
|
+
error?: string
|
|
124
|
+
cancelled?: boolean
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
outcome = JSON.parse(await native().mediaSaveAsync(id, sourceUri))
|
|
128
|
+
} finally {
|
|
129
|
+
active = null
|
|
130
|
+
}
|
|
131
|
+
if (outcome.cancelled === true) throw new MediaSaveCancelled()
|
|
132
|
+
if (!outcome.ok || typeof outcome.uri !== 'string') {
|
|
133
|
+
throw new MediaSaveFailed(outcome.error ?? 'the file could not be saved')
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
uri: outcome.uri,
|
|
137
|
+
displayName: outcome.displayName ?? '',
|
|
138
|
+
location: outcome.location ?? '',
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
cancel(): void {
|
|
143
|
+
if (active === null) return
|
|
144
|
+
native().mediaSaveCancel(active)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Delete pending rows a killed process left behind.
|
|
151
|
+
*
|
|
152
|
+
* Worth calling once at startup. A pending row is invisible to the gallery and
|
|
153
|
+
* still occupies storage, so nothing will ever prompt a user to clean one up.
|
|
154
|
+
*
|
|
155
|
+
* Scoped to this app's own still-pending rows under its own folder: a published
|
|
156
|
+
* item is never a candidate, and neither is anything another app wrote.
|
|
157
|
+
*
|
|
158
|
+
* @experimental
|
|
159
|
+
*/
|
|
160
|
+
export function sweepPendingMediaSaves(): {
|
|
161
|
+
swept: number
|
|
162
|
+
inspected: number
|
|
163
|
+
/** Pending rows seen and deliberately left alone. */
|
|
164
|
+
skipped: number
|
|
165
|
+
} {
|
|
166
|
+
if (!NativePictorVideoModule) return { swept: 0, inspected: 0, skipped: 0 }
|
|
167
|
+
const result = JSON.parse(NativePictorVideoModule.mediaSaveSweepPending()) as {
|
|
168
|
+
swept?: number
|
|
169
|
+
inspected?: number
|
|
170
|
+
skipped?: number
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
swept: result.swept ?? 0,
|
|
174
|
+
inspected: result.inspected ?? 0,
|
|
175
|
+
skipped: result.skipped ?? 0,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A debug safety cutoff on native memory.
|
|
3
|
+
*
|
|
4
|
+
* **Remove before production.** This exists because an earlier revision drove
|
|
5
|
+
* the native heap to 1.5 GB in sixty seconds and the process was killed —
|
|
6
|
+
* taking other applications on the device with it, since Android reclaims from
|
|
7
|
+
* everything before it gives up on the foreground app. A diagnostic run must
|
|
8
|
+
* not be able to do that again.
|
|
9
|
+
*
|
|
10
|
+
* Two independent triggers, because a leak can be fast or merely relentless:
|
|
11
|
+
*
|
|
12
|
+
* - **Ceiling.** An absolute limit, well under anything that would put the
|
|
13
|
+
* device under pressure.
|
|
14
|
+
* - **Sustained rise.** Memory that climbs across every sample of a window with
|
|
15
|
+
* no plateau, which is what a slow leak looks like and what a ceiling alone
|
|
16
|
+
* would take minutes to catch.
|
|
17
|
+
*
|
|
18
|
+
* Pure, so the rule is tested rather than trusted: a cutoff that misfires stops
|
|
19
|
+
* a good run, and one that never fires is not a cutoff.
|
|
20
|
+
*
|
|
21
|
+
* @experimental
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface MemoryGuard {
|
|
25
|
+
readonly ceilingBytes: number
|
|
26
|
+
readonly windowSamples: number
|
|
27
|
+
/** Minimum total climb across the window to count as a rise, in bytes. */
|
|
28
|
+
readonly riseBytes: number
|
|
29
|
+
samples: number[]
|
|
30
|
+
tripped: null | 'ceiling' | 'sustained-rise'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 250 MB. Healthy single-clip playback plateaus near 65 MB.
|
|
35
|
+
*
|
|
36
|
+
* Was 400 MB, which a 1 MB/s leak takes six minutes to reach — long enough for
|
|
37
|
+
* a five-minute run to finish, look successful, and leave the process fat
|
|
38
|
+
* enough that the next run pushed the device into reclaiming from other
|
|
39
|
+
* applications.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_CEILING_BYTES = 250 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
/** Forty samples at 4 Hz — ten seconds of uninterrupted climbing. */
|
|
44
|
+
export const DEFAULT_WINDOW_SAMPLES = 40
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 8 MB across the window: ~0.8 MB/s.
|
|
48
|
+
*
|
|
49
|
+
* Was 24 MB over three seconds — 8 MB/s, calibrated against a leak that hit
|
|
50
|
+
* 1.5 GB in a minute. It was blind to the 1.07 MB/s leak that actually got the
|
|
51
|
+
* process killed, so the cutoff never fired. A cutoff tuned only to the last
|
|
52
|
+
* failure is not a cutoff.
|
|
53
|
+
*
|
|
54
|
+
* A threshold this small is safe only because the rise must also be *strictly*
|
|
55
|
+
* increasing at every one of the forty samples. Real memory jitters; a leak
|
|
56
|
+
* does not.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_RISE_BYTES = 8 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
export function createMemoryGuard(
|
|
61
|
+
ceilingBytes = DEFAULT_CEILING_BYTES,
|
|
62
|
+
windowSamples = DEFAULT_WINDOW_SAMPLES,
|
|
63
|
+
riseBytes = DEFAULT_RISE_BYTES,
|
|
64
|
+
): MemoryGuard {
|
|
65
|
+
return {
|
|
66
|
+
ceilingBytes,
|
|
67
|
+
windowSamples: Math.max(3, Math.floor(windowSamples)),
|
|
68
|
+
riseBytes,
|
|
69
|
+
samples: [],
|
|
70
|
+
tripped: null,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record a reading and decide whether to stop.
|
|
76
|
+
*
|
|
77
|
+
* Once tripped it stays tripped: the caller has already torn the session down,
|
|
78
|
+
* and a guard that untrips would invite it to start again into the same leak.
|
|
79
|
+
*/
|
|
80
|
+
export function observeMemory(guard: MemoryGuard, bytes: number): MemoryGuard {
|
|
81
|
+
if (guard.tripped) return guard
|
|
82
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return guard
|
|
83
|
+
|
|
84
|
+
if (bytes >= guard.ceilingBytes) {
|
|
85
|
+
return { ...guard, samples: [...guard.samples, bytes].slice(-guard.windowSamples), tripped: 'ceiling' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const samples = [...guard.samples, bytes].slice(-guard.windowSamples)
|
|
89
|
+
if (samples.length < guard.windowSamples) return { ...guard, samples }
|
|
90
|
+
|
|
91
|
+
// **Strictly** increasing across the whole window, and a meaningful total
|
|
92
|
+
// climb. Both conditions matter, and strictness is the subtle one: allowing
|
|
93
|
+
// equal consecutive samples made a single large allocation followed by a flat
|
|
94
|
+
// plateau — a decoder starting up — read as a leak. A leak climbs at every
|
|
95
|
+
// sample; an allocation climbs once and then stops.
|
|
96
|
+
let climbing = true
|
|
97
|
+
for (let i = 1; i < samples.length; i++) {
|
|
98
|
+
if (samples[i]! <= samples[i - 1]!) {
|
|
99
|
+
climbing = false
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const climb = samples[samples.length - 1]! - samples[0]!
|
|
104
|
+
if (climbing && climb >= guard.riseBytes) {
|
|
105
|
+
return { ...guard, samples, tripped: 'sustained-rise' }
|
|
106
|
+
}
|
|
107
|
+
return { ...guard, samples }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A sentence a human can act on, or null while nothing is wrong. */
|
|
111
|
+
export function describeTrip(guard: MemoryGuard): string | null {
|
|
112
|
+
if (!guard.tripped) return null
|
|
113
|
+
const mb = (b: number) => (b / (1024 * 1024)).toFixed(0)
|
|
114
|
+
const latest = guard.samples[guard.samples.length - 1] ?? 0
|
|
115
|
+
return guard.tripped === 'ceiling'
|
|
116
|
+
? `native heap reached ${mb(latest)} MB (ceiling ${mb(guard.ceilingBytes)} MB) — session released`
|
|
117
|
+
: `native heap rose ${mb(latest - (guard.samples[0] ?? 0))} MB across ${guard.samples.length} samples without plateauing — session released`
|
|
118
|
+
}
|