@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
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compileAudioMixProgram,
|
|
3
|
+
type ExportSourceContext,
|
|
4
|
+
type MediaSource,
|
|
5
|
+
type VideoProject,
|
|
6
|
+
} from '@pictor-sdk/video-core'
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import { publishState } from './previewAudioDiagnostics'
|
|
10
|
+
import { resolveMixSources } from './mixSources'
|
|
11
|
+
import { requireNative } from './player'
|
|
12
|
+
import {
|
|
13
|
+
cancelPreviewMix,
|
|
14
|
+
preparePreviewMix,
|
|
15
|
+
previewMixKey,
|
|
16
|
+
sweepPreviewMixes,
|
|
17
|
+
} from './previewMix'
|
|
18
|
+
import {
|
|
19
|
+
decidePreviewAudio,
|
|
20
|
+
type PreviewAudioStatus,
|
|
21
|
+
} from './previewAudioSession'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Preview audio, bound to a screen's lifetime.
|
|
25
|
+
*
|
|
26
|
+
* The decisions live in `previewAudioSession.ts` and the mixing lives in
|
|
27
|
+
* native; this is the part that acts — starting jobs, loading the player,
|
|
28
|
+
* muting the other one, and refusing to publish an answer that has been
|
|
29
|
+
* superseded.
|
|
30
|
+
*
|
|
31
|
+
* ## Epochs
|
|
32
|
+
*
|
|
33
|
+
* Every load, play and seek carries the epoch of the mix it belongs to. A
|
|
34
|
+
* callback or a correction arriving with an older epoch is from a mix that has
|
|
35
|
+
* since been replaced, and acting on it would move the picture to match audio
|
|
36
|
+
* that is no longer playing. The epoch is bumped on every publish, so a late
|
|
37
|
+
* result cannot masquerade as the current one.
|
|
38
|
+
*
|
|
39
|
+
* @experimental
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** @experimental */
|
|
43
|
+
export interface UsePreviewAudioOptions {
|
|
44
|
+
project: VideoProject
|
|
45
|
+
/** The same resolver the exporter is given. */
|
|
46
|
+
resolveSource: (source: MediaSource, context: ExportSourceContext) => string | null | undefined
|
|
47
|
+
/** Off entirely — for a screen that never plays. */
|
|
48
|
+
enabled?: boolean
|
|
49
|
+
/** How far apart the players may drift before audio is moved. */
|
|
50
|
+
toleranceUs?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @experimental */
|
|
54
|
+
export interface PreviewAudioBinding {
|
|
55
|
+
status: PreviewAudioStatus
|
|
56
|
+
/** A sentence for a person when preparation failed. */
|
|
57
|
+
error: string | null
|
|
58
|
+
/** Whether playback may run. False while preparing or after a failure. */
|
|
59
|
+
canPlay: boolean
|
|
60
|
+
/** True once a mix matching the document is loaded. */
|
|
61
|
+
ready: boolean
|
|
62
|
+
/** How long the last preparation took, in ms. Null when it was cached. */
|
|
63
|
+
lastPrepareMs: number | null
|
|
64
|
+
/** Where that time went. Null for a cached mix. */
|
|
65
|
+
lastProfile: unknown
|
|
66
|
+
/*
|
|
67
|
+
`play`, `pause`, `seek`, `setRate` and `checkDrift` were here.
|
|
68
|
+
|
|
69
|
+
They were the second half of a two-player preview: this hook played the
|
|
70
|
+
prepared mix on its own player while the video player played the picture,
|
|
71
|
+
and `checkDrift` compared them and corrected past a tolerance. The preview
|
|
72
|
+
now plays both from one composition on one clock, so there is no second
|
|
73
|
+
position to compare and nothing to correct — the correction was a seek, and
|
|
74
|
+
a seek at a clip seam cost the decode it was trying to cover.
|
|
75
|
+
|
|
76
|
+
What is left is preparation: compile the program, produce the file, and say
|
|
77
|
+
whether it is ready. The composition opens it. @see useCompositionPlayback
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* The prepared mix, for a composition that carries the sound itself.
|
|
81
|
+
*
|
|
82
|
+
* Null until one matching the document exists. The preview plays audio and
|
|
83
|
+
* video from one composition on one clock, so what this hook contributes is
|
|
84
|
+
* the prepared program — the file and how long it runs — rather than a second
|
|
85
|
+
* player to keep in step with the first. `play`, `pause`, `seek`, `setRate`
|
|
86
|
+
* and `checkDrift` are what is left of that second player and are no longer
|
|
87
|
+
* part of the preview path.
|
|
88
|
+
*/
|
|
89
|
+
mix: { uri: string; durationUs: number } | null
|
|
90
|
+
/** Cancel, release the player and drop the mix. */
|
|
91
|
+
release(): void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @experimental */
|
|
95
|
+
export function usePreviewAudio(options: UsePreviewAudioOptions): PreviewAudioBinding {
|
|
96
|
+
const { project, enabled = true } = options
|
|
97
|
+
const resolveSource = useRef(options.resolveSource)
|
|
98
|
+
resolveSource.current = options.resolveSource
|
|
99
|
+
|
|
100
|
+
const [status, setStatusState] = useState<PreviewAudioStatus>('source')
|
|
101
|
+
const [error, setErrorState] = useState<string | null>(null)
|
|
102
|
+
const [readyKey, setReadyKeyState] = useState<string | null>(null)
|
|
103
|
+
const [lastPrepareMs, setLastPrepareMsState] = useState<number | null>(null)
|
|
104
|
+
const [lastProfile, setLastProfileState] = useState<unknown>(null)
|
|
105
|
+
const [mix, setMix] = useState<{ uri: string; durationUs: number } | null>(null)
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
Every publication goes through one seam. Five setters scattered across
|
|
109
|
+
three effects is exactly the shape that makes "does rendering cause this"
|
|
110
|
+
unanswerable, and routing them here costs a call and makes it a switch.
|
|
111
|
+
See `previewAudioDiagnostics`.
|
|
112
|
+
*/
|
|
113
|
+
// Memoized so each publisher has one identity for the hook's whole life:
|
|
114
|
+
// React's setters are stable, so these never rebuild, and the effects below
|
|
115
|
+
// can depend on them without that dependency meaning anything at runtime.
|
|
116
|
+
const setStatus = useMemo(() => publishState('status', setStatusState), [setStatusState])
|
|
117
|
+
const setError = useMemo(() => publishState('error', setErrorState), [setErrorState])
|
|
118
|
+
const setReadyKey = useMemo(
|
|
119
|
+
() => publishState('readyKey', setReadyKeyState),
|
|
120
|
+
[setReadyKeyState],
|
|
121
|
+
)
|
|
122
|
+
const setLastPrepareMs = useMemo(
|
|
123
|
+
() => publishState('lastPrepareMs', setLastPrepareMsState),
|
|
124
|
+
[setLastPrepareMsState],
|
|
125
|
+
)
|
|
126
|
+
const setLastProfile = useMemo(
|
|
127
|
+
() => publishState('lastProfile', setLastProfileState),
|
|
128
|
+
[setLastProfileState],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const preparingKey = useRef<string | null>(null)
|
|
132
|
+
const failedKey = useRef<string | null>(null)
|
|
133
|
+
const epoch = useRef(0)
|
|
134
|
+
const mounted = useRef(true)
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What this document wants, and what it needs to decode.
|
|
138
|
+
*
|
|
139
|
+
* Recomputed whenever the project changes and keyed by the compiled program,
|
|
140
|
+
* so an edit that cannot change the sound produces the same key and starts
|
|
141
|
+
* nothing.
|
|
142
|
+
*/
|
|
143
|
+
const wanted = useMemo(() => {
|
|
144
|
+
if (!enabled || project.audio.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
key: '',
|
|
147
|
+
programJson: '',
|
|
148
|
+
urisJson: '',
|
|
149
|
+
durationUs: 0,
|
|
150
|
+
unresolved: [] as string[],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const program = compileAudioMixProgram(project)
|
|
154
|
+
const resolved = resolveMixSources(project, program, (s, c) => resolveSource.current(s, c))
|
|
155
|
+
const programJson = JSON.stringify(program)
|
|
156
|
+
const urisJson = JSON.stringify(resolved.uris)
|
|
157
|
+
return {
|
|
158
|
+
key: resolved.unresolved.length > 0 ? '' : previewMixKey(programJson, urisJson),
|
|
159
|
+
programJson,
|
|
160
|
+
urisJson,
|
|
161
|
+
durationUs: Number(program.durationUs),
|
|
162
|
+
unresolved: resolved.unresolved,
|
|
163
|
+
}
|
|
164
|
+
}, [project, enabled])
|
|
165
|
+
|
|
166
|
+
const decision = decidePreviewAudio({
|
|
167
|
+
wantedKey: wanted.key,
|
|
168
|
+
readyKey,
|
|
169
|
+
preparingKey: preparingKey.current,
|
|
170
|
+
failedKey: failedKey.current,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/* A source that cannot be resolved is a failure with a name, not a wait. */
|
|
174
|
+
const unresolved = wanted.unresolved.length > 0 && enabled && project.audio.length > 0
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
mounted.current = true
|
|
178
|
+
return () => {
|
|
179
|
+
mounted.current = false
|
|
180
|
+
}
|
|
181
|
+
}, [])
|
|
182
|
+
|
|
183
|
+
/** Silence the other player exactly when the mix owns the sound. */
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!enabled) return
|
|
186
|
+
try {
|
|
187
|
+
requireNative().previewMuteSourceAudio(decision.muteSourceAudio || unresolved)
|
|
188
|
+
} catch {
|
|
189
|
+
// No adapter. The decision still stands for everything else.
|
|
190
|
+
}
|
|
191
|
+
}, [decision.muteSourceAudio, unresolved, enabled])
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (unresolved) {
|
|
195
|
+
setStatus('failed')
|
|
196
|
+
setError(wanted.unresolved[0] ?? 'a sound could not be read')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
setStatus(decision.status)
|
|
200
|
+
if (decision.status !== 'failed') setError(null)
|
|
201
|
+
}, [decision.status, unresolved, wanted.unresolved, setError, setStatus])
|
|
202
|
+
|
|
203
|
+
/** Start a job when the document wants a mix nobody is preparing. */
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!decision.shouldPrepare || unresolved) return
|
|
206
|
+
const key = wanted.key
|
|
207
|
+
preparingKey.current = key
|
|
208
|
+
const jobId = `preview-${key.slice(0, 8)}-${epoch.current + 1}`
|
|
209
|
+
const startedAt = Date.now()
|
|
210
|
+
|
|
211
|
+
void preparePreviewMix(jobId, wanted.programJson, wanted.urisJson).then((result) => {
|
|
212
|
+
if (!mounted.current) return
|
|
213
|
+
/*
|
|
214
|
+
Two independent staleness checks, and both are needed. The job may have
|
|
215
|
+
been superseded inside native — it says so — and the document may have
|
|
216
|
+
moved on while the promise was in flight, which native cannot know.
|
|
217
|
+
*/
|
|
218
|
+
if (result.ok === false && result.superseded) return
|
|
219
|
+
if (key !== preparingKey.current) return
|
|
220
|
+
preparingKey.current = null
|
|
221
|
+
|
|
222
|
+
if (result.ok === false) {
|
|
223
|
+
failedKey.current = key
|
|
224
|
+
setError(result.reason)
|
|
225
|
+
setStatus('failed')
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
failedKey.current = null
|
|
230
|
+
epoch.current += 1
|
|
231
|
+
setLastPrepareMs(result.cached ? null : Date.now() - startedAt)
|
|
232
|
+
setLastProfile(result.cached ? null : (result.profile ?? null))
|
|
233
|
+
try {
|
|
234
|
+
requireNative().previewAudioLoad(result.uri, epoch.current)
|
|
235
|
+
} catch {
|
|
236
|
+
failedKey.current = key
|
|
237
|
+
setError('the prepared mix could not be opened')
|
|
238
|
+
setStatus('failed')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
/*
|
|
242
|
+
Published for the composition, which is what plays it.
|
|
243
|
+
|
|
244
|
+
The duration comes from the compiled program rather than from the file:
|
|
245
|
+
it is the length the mix was trimmed to, and taking it from the compiler
|
|
246
|
+
keeps it on the same side of the boundary as every other timing value.
|
|
247
|
+
*/
|
|
248
|
+
setMix({ uri: result.uri, durationUs: Number(wanted.durationUs) })
|
|
249
|
+
setReadyKey(result.key)
|
|
250
|
+
// Bounded: keep the one being played and let the rest go.
|
|
251
|
+
sweepPreviewMixes([result.key])
|
|
252
|
+
})
|
|
253
|
+
}, [
|
|
254
|
+
decision.shouldPrepare,
|
|
255
|
+
wanted.key,
|
|
256
|
+
wanted.programJson,
|
|
257
|
+
wanted.urisJson,
|
|
258
|
+
unresolved,
|
|
259
|
+
setError,
|
|
260
|
+
setLastPrepareMs,
|
|
261
|
+
setLastProfile,
|
|
262
|
+
setReadyKey,
|
|
263
|
+
setStatus,
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
The second player's transport lived here: `play`, `pause`, `seek`,
|
|
268
|
+
`setRate` and `checkDrift`.
|
|
269
|
+
|
|
270
|
+
Deleted with the architecture that needed them. The preview plays the mix
|
|
271
|
+
from the composition, on the same clock as the picture, so there is no
|
|
272
|
+
second position to seek, rate or compare. `previewSyncStop` survives in
|
|
273
|
+
`release` because a preparation in flight still has to be abandoned.
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
const release = useCallback(() => {
|
|
277
|
+
cancelPreviewMix()
|
|
278
|
+
try {
|
|
279
|
+
requireNative().previewSyncStop('release')
|
|
280
|
+
requireNative().previewAudioRelease()
|
|
281
|
+
requireNative().previewMuteSourceAudio(false)
|
|
282
|
+
} catch {
|
|
283
|
+
// Teardown paths overlap on purpose; each must be safe after the others.
|
|
284
|
+
}
|
|
285
|
+
preparingKey.current = null
|
|
286
|
+
setReadyKey(null)
|
|
287
|
+
setMix(null)
|
|
288
|
+
}, [setReadyKey])
|
|
289
|
+
|
|
290
|
+
/* Backgrounding and unmount both stop work and give the player back. */
|
|
291
|
+
useEffect(() => release, [release])
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
status: unresolved ? 'failed' : status,
|
|
295
|
+
error,
|
|
296
|
+
canPlay: !unresolved && decision.canPlay,
|
|
297
|
+
ready: decision.status === 'ready',
|
|
298
|
+
lastPrepareMs,
|
|
299
|
+
lastProfile,
|
|
300
|
+
// Null until a mix matching the document is ready, so a composition built
|
|
301
|
+
// from it is never built from a stale one.
|
|
302
|
+
mix: decision.status === 'ready' ? mix : null,
|
|
303
|
+
release,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { AppState, type AppStateStatus } from 'react-native'
|
|
3
|
+
import { runOnJS, runOnUI, useSharedValue } from 'react-native-reanimated'
|
|
4
|
+
import { NO_SURFACE } from './playlistSession'
|
|
5
|
+
import { AndroidVideoPlayer } from './player'
|
|
6
|
+
import { nextOwnerToken, surfaceOwner, type SurfaceCreateResult } from './surfaceOwner'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The presentation surface, and whether it is usable *right now*.
|
|
10
|
+
*
|
|
11
|
+
* This was host code, and every host would have got it wrong in the same way.
|
|
12
|
+
* `create()` must run on the UI runtime, where Skia's GL context lives, and
|
|
13
|
+
* `runOnUI` **schedules** that rather than performing it — so a host that sets
|
|
14
|
+
* its state beside the call declares the surface ready a frame or two before the
|
|
15
|
+
* texture exists. A playlist built in that window is refused outright, and the
|
|
16
|
+
* editor shows clips on the timeline with a player holding nothing.
|
|
17
|
+
*
|
|
18
|
+
* Readiness is therefore reported from *inside* the worklet, after the texture
|
|
19
|
+
* and the native `Surface` both exist, and it carries **the generation native
|
|
20
|
+
* assigned**. This hook keeps no counter of its own: a second counter is a
|
|
21
|
+
* second answer to "which surface is live", and the fault it replaced was
|
|
22
|
+
* exactly two answers disagreeing.
|
|
23
|
+
*
|
|
24
|
+
* ## Level-triggered, not one-shot
|
|
25
|
+
*
|
|
26
|
+
* The surface is re-examined whenever the app returns to the foreground, and
|
|
27
|
+
* marked unavailable when it leaves. Backgrounding is the ordinary case, not an
|
|
28
|
+
* edge case: choosing media means going to another activity and coming back, and
|
|
29
|
+
* the clips arrive in the document *while there is no surface to prepare
|
|
30
|
+
* against*. Anything that only reacts to the document changing has already
|
|
31
|
+
* missed its chance by the time it returns.
|
|
32
|
+
*
|
|
33
|
+
* ## Serialisation is process-wide
|
|
34
|
+
*
|
|
35
|
+
* Creation is arbitrated by {@link surfaceOwner}, a module-level singleton,
|
|
36
|
+
* because the thing it guards — the one external texture — is one per process.
|
|
37
|
+
* A lock held per hook instance is no lock at all: a remount brings a fresh one,
|
|
38
|
+
* two creates overlap, and the second overwrites the native handles the first is
|
|
39
|
+
* still holding. Nor does unmounting release it: the earlier attempt is still
|
|
40
|
+
* out on the UI runtime and will still come back.
|
|
41
|
+
*
|
|
42
|
+
* A surface built by an attempt whose owner has since gone is **released**, not
|
|
43
|
+
* ignored. It exists, it is registered, and Media3 could attach to it.
|
|
44
|
+
*
|
|
45
|
+
* @experimental
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** How often to retry creating a surface while there is none, in milliseconds. */
|
|
49
|
+
const CREATE_RETRY_MS = 250
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The one stable slot, installed on first ask.
|
|
53
|
+
*
|
|
54
|
+
* Synchronous, and callable during a render, because the consumer has to be able
|
|
55
|
+
* to capture it *before* any surface exists. An effect is too late: the frame
|
|
56
|
+
* callback is built on the first render, and anything it captures then is what
|
|
57
|
+
* it keeps.
|
|
58
|
+
*
|
|
59
|
+
* The object never changes. `create` fills its target in place and retirement
|
|
60
|
+
* clears it, so every call through it resolves to whatever the slot points at
|
|
61
|
+
* now. There is deliberately nothing replaceable to hold.
|
|
62
|
+
*
|
|
63
|
+
* @experimental
|
|
64
|
+
*/
|
|
65
|
+
export function videoSurfaceSlot(): VideoSurfaceHandle | undefined {
|
|
66
|
+
const published = (globalThis as unknown as { __pictorVideoSurface?: VideoSurfaceHandle })
|
|
67
|
+
.__pictorVideoSurface
|
|
68
|
+
if (published) return published
|
|
69
|
+
if (!AndroidVideoPlayer.installVideoSurface()) return undefined
|
|
70
|
+
return (globalThis as unknown as { __pictorVideoSurface?: VideoSurfaceHandle })
|
|
71
|
+
.__pictorVideoSurface
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @experimental */
|
|
75
|
+
export interface VideoSurfaceOptions {
|
|
76
|
+
/** Backing size of the texture. Frames are scaled into it. */
|
|
77
|
+
width: number
|
|
78
|
+
height: number
|
|
79
|
+
enabled?: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The surface as the UI runtime sees it.
|
|
84
|
+
*
|
|
85
|
+
* Structural rather than an imported Android type: nothing platform-specific
|
|
86
|
+
* reaches Pictor's public API.
|
|
87
|
+
*
|
|
88
|
+
* @experimental
|
|
89
|
+
*/
|
|
90
|
+
export interface VideoSurfaceHandle {
|
|
91
|
+
create(
|
|
92
|
+
width: number,
|
|
93
|
+
height: number,
|
|
94
|
+
): { textureId: number; ok: boolean; generation: number; error?: string }
|
|
95
|
+
generation(): number
|
|
96
|
+
alive(): boolean
|
|
97
|
+
/** Release a named generation. Does nothing if it is not the one held. */
|
|
98
|
+
releaseGeneration(generation: number): boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @experimental */
|
|
102
|
+
export interface VideoSurface {
|
|
103
|
+
/**
|
|
104
|
+
* The stable slot.
|
|
105
|
+
*
|
|
106
|
+
* Available from the first render and never replaced, so a consumer captures
|
|
107
|
+
* it once and reads through it. It answers `generation() === 0` while empty,
|
|
108
|
+
* which is how a renderer knows there is nothing to draw — a null would have
|
|
109
|
+
* to be captured, and a captured null is what it stays.
|
|
110
|
+
*/
|
|
111
|
+
surface: unknown | undefined
|
|
112
|
+
/**
|
|
113
|
+
* The live generation, exactly as native reported it. 0 when there is none.
|
|
114
|
+
*
|
|
115
|
+
* Feed to `useMedia3Playback`. Every change is a reason to reconcile the
|
|
116
|
+
* playlist, including the change back to 0.
|
|
117
|
+
*/
|
|
118
|
+
generation: number
|
|
119
|
+
/** Failure to install or create, for a host's error slot. Empty when healthy. */
|
|
120
|
+
error: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @experimental */
|
|
124
|
+
export function useVideoSurface(options: VideoSurfaceOptions): VideoSurface {
|
|
125
|
+
const { width, height, enabled = true } = options
|
|
126
|
+
const [readiness, setReadiness] = useState({ generation: NO_SURFACE, error: '' })
|
|
127
|
+
// Resolved during render, not in an effect: a consumer building a frame
|
|
128
|
+
// callback on its first render must have the slot to capture by then.
|
|
129
|
+
const handleRef = useRef<VideoSurfaceHandle | undefined>(undefined)
|
|
130
|
+
if (!handleRef.current) handleRef.current = videoSurfaceSlot()
|
|
131
|
+
const textureId = useSharedValue(0)
|
|
132
|
+
|
|
133
|
+
/** This hook instance's identity, so the owner can tell it from a remount. */
|
|
134
|
+
const tokenRef = useRef(0)
|
|
135
|
+
if (tokenRef.current === 0) tokenRef.current = nextOwnerToken()
|
|
136
|
+
const token = tokenRef.current
|
|
137
|
+
|
|
138
|
+
const [attempt, setAttempt] = useState(0)
|
|
139
|
+
|
|
140
|
+
const ownerRef = useRef<ReturnType<typeof surfaceOwner> | undefined>(undefined)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve an attempt on the process-wide owner.
|
|
144
|
+
*
|
|
145
|
+
* Reached through `runOnJS` from inside the worklet, which is the only place
|
|
146
|
+
* that can honestly say the texture and the `Surface` exist.
|
|
147
|
+
*/
|
|
148
|
+
const complete = useCallback((result: SurfaceCreateResult) => {
|
|
149
|
+
ownerRef.current?.complete(result)
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
const owner = surfaceOwner({
|
|
153
|
+
create() {
|
|
154
|
+
const published = handleRef.current
|
|
155
|
+
if (!published) {
|
|
156
|
+
complete({ ok: false, generation: 0, textureId: 0, error: '__pictorVideoSurface missing' })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
runOnUI(() => {
|
|
160
|
+
'worklet'
|
|
161
|
+
const created = published.create(width, height)
|
|
162
|
+
textureId.value = created.textureId
|
|
163
|
+
if (created.ok && !(created.generation > 0)) {
|
|
164
|
+
runOnJS(complete)({
|
|
165
|
+
ok: false,
|
|
166
|
+
generation: 0,
|
|
167
|
+
textureId: created.textureId,
|
|
168
|
+
error: 'adapter too old: create() reports no generation',
|
|
169
|
+
})
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
runOnJS(complete)({
|
|
173
|
+
ok: created.ok && created.textureId > 0,
|
|
174
|
+
generation: created.generation,
|
|
175
|
+
textureId: created.textureId,
|
|
176
|
+
// The native reason, not a summary of it. "No EGL context current on
|
|
177
|
+
// this thread" and "no JNIEnv" send a reader to different places.
|
|
178
|
+
error: created.ok ? '' : created.error || 'surface create failed',
|
|
179
|
+
})
|
|
180
|
+
})()
|
|
181
|
+
},
|
|
182
|
+
retire(generation: number) {
|
|
183
|
+
const published = handleRef.current
|
|
184
|
+
if (!published || typeof published.releaseGeneration !== 'function') return
|
|
185
|
+
// On the UI runtime, because deleting a GL texture needs the context that
|
|
186
|
+
// owns it to be current — which is true on exactly one thread.
|
|
187
|
+
runOnUI(() => {
|
|
188
|
+
'worklet'
|
|
189
|
+
published.releaseGeneration(generation)
|
|
190
|
+
})()
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
ownerRef.current = owner
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const unsubscribe = owner.subscribe(() => setReadiness(owner.readiness))
|
|
197
|
+
setReadiness(owner.readiness)
|
|
198
|
+
return unsubscribe
|
|
199
|
+
}, [owner])
|
|
200
|
+
|
|
201
|
+
const ensure = useCallback(() => {
|
|
202
|
+
if (!enabled) return
|
|
203
|
+
// Through the same accessor the first render used. Calling
|
|
204
|
+
// `installVideoSurface` again here is what produced two host objects: the
|
|
205
|
+
// consumer captured the one installed during render, `create` ran on the one
|
|
206
|
+
// this effect installed over it, and the two reported different generations
|
|
207
|
+
// forever with neither being wrong about itself.
|
|
208
|
+
const published = videoSurfaceSlot()
|
|
209
|
+
if (!published) {
|
|
210
|
+
setReadiness((prev) => ({ ...prev, error: '__pictorVideoSurface missing' }))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
handleRef.current = published
|
|
214
|
+
|
|
215
|
+
// Already live: adopt it rather than build a second. Guarded because an
|
|
216
|
+
// adapter built before these accessors answers `undefined`, and calling that
|
|
217
|
+
// throws inside an effect — which presents as a blank screen rather than as
|
|
218
|
+
// the version mismatch it is.
|
|
219
|
+
if (
|
|
220
|
+
typeof published.alive === 'function' &&
|
|
221
|
+
typeof published.generation === 'function' &&
|
|
222
|
+
published.alive() &&
|
|
223
|
+
published.generation() !== NO_SURFACE
|
|
224
|
+
) {
|
|
225
|
+
owner.adopt(published.generation(), token)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
owner.request(token)
|
|
230
|
+
}, [enabled, owner, token])
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
ensure()
|
|
234
|
+
}, [ensure, attempt])
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Keep asking until there is a surface.
|
|
238
|
+
*
|
|
239
|
+
* `create` needs Skia's GL context current on the UI runtime, and that is only
|
|
240
|
+
* true once a canvas has drawn — which has not happened when this hook's first
|
|
241
|
+
* effect runs. Measured on a Pixel 7: the first attempt returns "no EGL
|
|
242
|
+
* context current on this thread", and a later one succeeds.
|
|
243
|
+
*
|
|
244
|
+
* A retry rather than a wait on some readiness signal, because there is no
|
|
245
|
+
* such signal: the context becoming current is a side effect of rendering, not
|
|
246
|
+
* an event anything publishes. The owner refuses to overlap attempts, so
|
|
247
|
+
* asking often is safe.
|
|
248
|
+
*/
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!enabled) return
|
|
251
|
+
if (readiness.generation !== NO_SURFACE) return
|
|
252
|
+
const id = setInterval(() => setAttempt((n) => n + 1), CREATE_RETRY_MS)
|
|
253
|
+
return () => clearInterval(id)
|
|
254
|
+
}, [enabled, readiness.generation])
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!enabled) return
|
|
258
|
+
const onChange = (status: AppStateStatus) => {
|
|
259
|
+
if (status === 'active') {
|
|
260
|
+
// Level-triggered: ask again rather than assume the answer survived.
|
|
261
|
+
ensure()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
// This owner is gone for now. The lock is deliberately not released — an
|
|
265
|
+
// attempt on the UI runtime is still out there — and a surface it produces
|
|
266
|
+
// will be retired rather than published to nobody.
|
|
267
|
+
owner.retireOwner(token)
|
|
268
|
+
}
|
|
269
|
+
const subscription = AppState.addEventListener('change', onChange)
|
|
270
|
+
return () => subscription.remove()
|
|
271
|
+
}, [enabled, ensure, owner, token])
|
|
272
|
+
|
|
273
|
+
useEffect(() => () => owner.retireOwner(token), [owner, token])
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
// Always the slot, never undefined once installed. Gating this on readiness
|
|
277
|
+
// is what put a null into the consumer's closure and kept it there.
|
|
278
|
+
surface: handleRef.current,
|
|
279
|
+
generation: readiness.generation,
|
|
280
|
+
error: readiness.error,
|
|
281
|
+
}
|
|
282
|
+
}
|