@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. 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
+ }