@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,492 @@
|
|
|
1
|
+
import {
|
|
2
|
+
playIntent,
|
|
3
|
+
playableDuration,
|
|
4
|
+
playlistOf,
|
|
5
|
+
projectDuration,
|
|
6
|
+
projectTimeToWindow,
|
|
7
|
+
windowToProjectTime,
|
|
8
|
+
type PlayIntent,
|
|
9
|
+
type PlaylistItem,
|
|
10
|
+
type UnsupportedSource,
|
|
11
|
+
type VideoProject,
|
|
12
|
+
} from '@pictor-sdk/video-core'
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { AppState } from 'react-native'
|
|
15
|
+
import { Media3, type Media3State } from './media3'
|
|
16
|
+
import { createPlaybackLifecycle, type PlaybackLifecycle } from './playbackLifecycle'
|
|
17
|
+
import {
|
|
18
|
+
classifyStall,
|
|
19
|
+
shouldRecover,
|
|
20
|
+
type RecoverySample,
|
|
21
|
+
type StallKind,
|
|
22
|
+
} from './surfaceRecovery'
|
|
23
|
+
import {
|
|
24
|
+
NO_SURFACE,
|
|
25
|
+
afterPrepare,
|
|
26
|
+
idlePlaylistSession,
|
|
27
|
+
shouldPrepare,
|
|
28
|
+
} from './playlistSession'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keep a Media3 playlist in step with a project, and the playhead with playback.
|
|
32
|
+
*
|
|
33
|
+
* Two directions, deliberately separated:
|
|
34
|
+
*
|
|
35
|
+
* - **Project → player.** When the clips, their order or their trims change,
|
|
36
|
+
* the playlist is replaced. The current *project time* is preserved across
|
|
37
|
+
* that, not the window index — deleting the second of four clips must not
|
|
38
|
+
* leave the playhead in whatever the second window now happens to be.
|
|
39
|
+
* - **Player → playhead.** While playing, the player's position is polled at a
|
|
40
|
+
* readable rate and mapped back to project time. This is a **throttled
|
|
41
|
+
* semantic update**, not a per-frame one: the picture is composed on the UI
|
|
42
|
+
* runtime from the external texture and never waits for React.
|
|
43
|
+
*
|
|
44
|
+
* The playlist is only replaced when it genuinely differs. A trim drag
|
|
45
|
+
* dispatches a command per finger sample, and rebuilding the playlist on each
|
|
46
|
+
* would restart decoding many times a second.
|
|
47
|
+
*
|
|
48
|
+
* @experimental
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** @experimental */
|
|
52
|
+
export interface UseMedia3PlaybackOptions {
|
|
53
|
+
project: VideoProject
|
|
54
|
+
/** Timeline time the host wants shown. */
|
|
55
|
+
playheadUs: number
|
|
56
|
+
/** Called with the project time as playback advances. Throttled. */
|
|
57
|
+
onPositionUs?: (timeUs: number) => void
|
|
58
|
+
/** How often to poll the player, in milliseconds. */
|
|
59
|
+
pollMs?: number
|
|
60
|
+
/**
|
|
61
|
+
* The live presentation surface generation, from `useVideoSurface`.
|
|
62
|
+
*
|
|
63
|
+
* Not a boolean. A playlist prepared against a retired surface renders into
|
|
64
|
+
* something nobody is watching — the player decodes, releases frames and
|
|
65
|
+
* reports no error — so the question is *which* surface, not whether one
|
|
66
|
+
* exists. Every change is a reason to reconcile, including the change to 0.
|
|
67
|
+
*/
|
|
68
|
+
surfaceGeneration?: number
|
|
69
|
+
enabled?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @experimental */
|
|
73
|
+
export interface Media3Playback {
|
|
74
|
+
playing: boolean
|
|
75
|
+
/**
|
|
76
|
+
* Start or resume playback, replaying from zero when there is nothing left.
|
|
77
|
+
*
|
|
78
|
+
* Returns what it decided, so a host can show it or count it. See
|
|
79
|
+
* `playIntent` in `@pictor-sdk/video-core` for why the end is a special case.
|
|
80
|
+
*/
|
|
81
|
+
play(): PlayIntent
|
|
82
|
+
pause(): void
|
|
83
|
+
setRate(rate: number): void
|
|
84
|
+
/** Seek in project time. Mapped to the player's own coordinates. */
|
|
85
|
+
seek(timeUs: number): void
|
|
86
|
+
state: Media3State | null
|
|
87
|
+
/** The windows currently loaded, for diagnostics and gates. */
|
|
88
|
+
items: readonly PlaylistItem[]
|
|
89
|
+
/**
|
|
90
|
+
* Clips the player cannot open. Non-empty means playback is **not** loaded.
|
|
91
|
+
*
|
|
92
|
+
* Surfaced rather than swallowed: the alternative is an editor that plays a
|
|
93
|
+
* shorter timeline than it draws, which reads as a synchronisation bug.
|
|
94
|
+
*/
|
|
95
|
+
unsupported: readonly UnsupportedSource[]
|
|
96
|
+
/**
|
|
97
|
+
* Which half of the pipeline stalled after a surface restoration, or `null`.
|
|
98
|
+
*
|
|
99
|
+
* Non-null means a black picture was detected and the handshake re-run — a
|
|
100
|
+
* host should surface it rather than show a silent audio-only result.
|
|
101
|
+
*/
|
|
102
|
+
stall: StallKind | null
|
|
103
|
+
/**
|
|
104
|
+
* This screen's ownership of the shared player, for diagnostics and gates.
|
|
105
|
+
*
|
|
106
|
+
* Exposed so a host can log which path stopped playback and assert that a
|
|
107
|
+
* screen released exactly once.
|
|
108
|
+
*/
|
|
109
|
+
lifecycle: PlaybackLifecycle
|
|
110
|
+
/**
|
|
111
|
+
* How far the timeline and the player disagree, in microseconds. Zero when
|
|
112
|
+
* everything is playable, and asserted by the device gates.
|
|
113
|
+
*/
|
|
114
|
+
divergenceUs: number
|
|
115
|
+
/**
|
|
116
|
+
* The player's position in project time, read now, without rendering.
|
|
117
|
+
*
|
|
118
|
+
* For a host driving a UI-thread playhead: it samples this a few times a
|
|
119
|
+
* second and advances between samples itself. Returns `null` when the player
|
|
120
|
+
* has no position to give — before a playlist is loaded, or after release.
|
|
121
|
+
*/
|
|
122
|
+
positionUs(): number | null
|
|
123
|
+
/**
|
|
124
|
+
* The rate actually in force: clip speed and preview rate, composed and
|
|
125
|
+
* clamped by the adapter. A host showing a rate should show this one.
|
|
126
|
+
*/
|
|
127
|
+
effectiveRate: number
|
|
128
|
+
/**
|
|
129
|
+
* The last seek this hook actually issued.
|
|
130
|
+
*
|
|
131
|
+
* Global time in, and the `{windowIndex, localUs}` it was converted to. A
|
|
132
|
+
* player reporting an unexpected position is either being sent the wrong
|
|
133
|
+
* thing or mishandling the right thing, and those need different fixes — this
|
|
134
|
+
* is what tells them apart instead of leaving it to arithmetic on a
|
|
135
|
+
* screenshot.
|
|
136
|
+
*/
|
|
137
|
+
lastSeek: { globalUs: number; windowIndex: number; localUs: number } | null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DEFAULT_POLL_MS = 100
|
|
141
|
+
|
|
142
|
+
/** How often a refused preparation is retried, in milliseconds. */
|
|
143
|
+
const RETRY_INTERVAL_MS = 250
|
|
144
|
+
|
|
145
|
+
/** @experimental */
|
|
146
|
+
export function useMedia3Playback(options: UseMedia3PlaybackOptions): Media3Playback {
|
|
147
|
+
const { project, playheadUs, enabled = true } = options
|
|
148
|
+
const surfaceGeneration = options.surfaceGeneration ?? NO_SURFACE
|
|
149
|
+
const pollMs = options.pollMs ?? DEFAULT_POLL_MS
|
|
150
|
+
const [state, setState] = useState<Media3State | null>(null)
|
|
151
|
+
const [playing, setPlaying] = useState(false)
|
|
152
|
+
|
|
153
|
+
const playlist = useMemo(() => playlistOf(project), [project])
|
|
154
|
+
const items = playlist.items
|
|
155
|
+
const divergenceUs = useMemo(
|
|
156
|
+
() => projectDuration(project) - playableDuration(playlist),
|
|
157
|
+
[project, playlist],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A stable description of the playlist.
|
|
162
|
+
*
|
|
163
|
+
* Compared as a string rather than by reference: every edit produces a new
|
|
164
|
+
* project object, but most edits — a selection, a playhead move, an opacity
|
|
165
|
+
* change — do not change what the player should be playing.
|
|
166
|
+
*/
|
|
167
|
+
const signature = useMemo(
|
|
168
|
+
() =>
|
|
169
|
+
items
|
|
170
|
+
.map((i) => `${JSON.stringify(i.source)}|${i.startUs}|${i.endUs}|${i.speed}|${i.muted}`)
|
|
171
|
+
.join(';') +
|
|
172
|
+
// Unsupported clips belong in the signature even though they are not
|
|
173
|
+
// windows. Adding a photo between two videos leaves the video windows
|
|
174
|
+
// identical, so a signature built from items alone would not change and
|
|
175
|
+
// the player would keep playing a timeline that had grown underneath it —
|
|
176
|
+
// the exact divergence this is all meant to prevent.
|
|
177
|
+
`!${playlist.unsupported.map((u) => `${u.clipId}:${u.reason}`).join(',')}`,
|
|
178
|
+
[items, playlist.unsupported],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const playheadRef = useRef(playheadUs)
|
|
182
|
+
playheadRef.current = playheadUs
|
|
183
|
+
const itemsRef = useRef(items)
|
|
184
|
+
itemsRef.current = items
|
|
185
|
+
|
|
186
|
+
const session = useRef(idlePlaylistSession())
|
|
187
|
+
/** Bumped by the retry timer, purely to run the reconciliation again. */
|
|
188
|
+
const [retry, setRetry] = useState(0)
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Reconcile what Media3 holds with what the document and the surface imply.
|
|
192
|
+
*
|
|
193
|
+
* Level-triggered. The old version fired on the playlist *changing*, which is
|
|
194
|
+
* the one moment it must not rely on: clips are added while the system picker
|
|
195
|
+
* is closing and there is no surface to prepare against, so the change has
|
|
196
|
+
* already happened by the time preparation becomes possible. See
|
|
197
|
+
* `playlistSession.ts`.
|
|
198
|
+
*/
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!enabled) return
|
|
201
|
+
const intent = { signature, generation: surfaceGeneration }
|
|
202
|
+
if (!shouldPrepare(session.current, intent)) return
|
|
203
|
+
|
|
204
|
+
let ok: boolean
|
|
205
|
+
try {
|
|
206
|
+
if (playlist.unsupported.length > 0 || items.length === 0) {
|
|
207
|
+
// Deliberately not loaded. The host is told why through `unsupported`,
|
|
208
|
+
// and the timeline keeps drawing — what it must not do is play
|
|
209
|
+
// something shorter than what it shows and call that working.
|
|
210
|
+
ok = Media3.setPlaylist({ items: [], unsupported: [] }) !== false
|
|
211
|
+
} else {
|
|
212
|
+
// Preserve project time, not window index. The window that held the
|
|
213
|
+
// playhead may not be the same window — or may not exist — after the
|
|
214
|
+
// edit.
|
|
215
|
+
const start = projectTimeToWindow(items, playheadRef.current) ?? {
|
|
216
|
+
windowIndex: 0,
|
|
217
|
+
positionUs: 0,
|
|
218
|
+
}
|
|
219
|
+
ok = Media3.setPlaylist(playlist, start) !== false
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// A refusal, not a crash. Recording it as applied is what left an editor
|
|
223
|
+
// waiting for an edit the user had already made.
|
|
224
|
+
ok = false
|
|
225
|
+
}
|
|
226
|
+
session.current = afterPrepare(session.current, intent, ok)
|
|
227
|
+
}, [signature, items, playlist, enabled, surfaceGeneration, retry])
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Retry a refused preparation without waiting for anything else to move.
|
|
231
|
+
*
|
|
232
|
+
* A surface can arrive without its generation changing again, and a user who
|
|
233
|
+
* has added their clips will not add them a second time to prod it.
|
|
234
|
+
*/
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!enabled) return
|
|
237
|
+
if (!session.current.pending) return
|
|
238
|
+
const id = setInterval(() => setRetry((n) => n + 1), RETRY_INTERVAL_MS)
|
|
239
|
+
return () => clearInterval(id)
|
|
240
|
+
}, [enabled, retry, surfaceGeneration, signature])
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
This screen's ownership of the process-wide player.
|
|
244
|
+
|
|
245
|
+
Media3's player outlives any screen, so leaving the editor while playing
|
|
246
|
+
left the audio running and backgrounding the app did the same — the picture
|
|
247
|
+
stopped because its surface was retired, and nothing had told the player.
|
|
248
|
+
One lifecycle per hook instance: a screen mounted later starts from nothing
|
|
249
|
+
rather than inheriting what the last one was doing.
|
|
250
|
+
*/
|
|
251
|
+
const stallRef = useRef<StallKind | null>(null)
|
|
252
|
+
const surfaceGenerationRef = useRef(surfaceGeneration ?? 0)
|
|
253
|
+
surfaceGenerationRef.current = surfaceGeneration ?? 0
|
|
254
|
+
const lifecycleRef = useRef<PlaybackLifecycle | null>(null)
|
|
255
|
+
if (lifecycleRef.current === null) {
|
|
256
|
+
lifecycleRef.current = createPlaybackLifecycle({
|
|
257
|
+
pause: () => Media3.pause(),
|
|
258
|
+
release: () => Media3.release(),
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
const lifecycle = lifecycleRef.current
|
|
262
|
+
/*
|
|
263
|
+
The epoch, made visible to React.
|
|
264
|
+
|
|
265
|
+
The polls capture an epoch when they start and compare on every tick, so
|
|
266
|
+
resuming has to restart them under the new one — otherwise a screen that
|
|
267
|
+
came back from the background would poll forever under an epoch that no
|
|
268
|
+
longer matches and never report a position again.
|
|
269
|
+
*/
|
|
270
|
+
const [lifecycleEpoch, setLifecycleEpoch] = useState(lifecycle.epoch)
|
|
271
|
+
|
|
272
|
+
/*
|
|
273
|
+
Backgrounding pauses; returning re-arms.
|
|
274
|
+
|
|
275
|
+
Suspend rather than teardown: the picker comes up over the editor as a
|
|
276
|
+
background transition, and rebuilding a player the screen never lost would
|
|
277
|
+
be a stall nobody asked for. The document is not touched either way —
|
|
278
|
+
playback stopping says nothing about where the playhead should be.
|
|
279
|
+
*/
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const subscription = AppState.addEventListener('change', (next) => {
|
|
282
|
+
if (next === 'active') {
|
|
283
|
+
lifecycle.resume()
|
|
284
|
+
setLifecycleEpoch(lifecycle.epoch)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
lifecycle.suspend('background')
|
|
288
|
+
setLifecycleEpoch(lifecycle.epoch)
|
|
289
|
+
setPlaying(false)
|
|
290
|
+
})
|
|
291
|
+
return () => subscription.remove()
|
|
292
|
+
}, [lifecycle])
|
|
293
|
+
|
|
294
|
+
/*
|
|
295
|
+
The screen going is the end of its playback, exactly once.
|
|
296
|
+
|
|
297
|
+
Last of the effects declared here, so it is the first to be cleaned up: a
|
|
298
|
+
teardown that ran after the polls were cleared would release a player the
|
|
299
|
+
polls were still reading.
|
|
300
|
+
*/
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
return () => {
|
|
303
|
+
lifecycle.teardown('unmount')
|
|
304
|
+
}
|
|
305
|
+
}, [lifecycle])
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
The watchdog for a surface that came back half-attached.
|
|
309
|
+
|
|
310
|
+
The failure it exists for reports nothing: after a background or a picker
|
|
311
|
+
the clock advances, audio plays, no error is raised and the picture is
|
|
312
|
+
black. A sample is kept from the last time the pipeline looked healthy, and
|
|
313
|
+
when the clock has moved for long enough without a frame being latched the
|
|
314
|
+
handshake is run again. `classifyStall` names which half is broken, because
|
|
315
|
+
re-preparing fixes a stale generation and does nothing for a consumer that
|
|
316
|
+
has stopped pulling.
|
|
317
|
+
*/
|
|
318
|
+
const healthy = useRef<RecoverySample | null>(null)
|
|
319
|
+
const [stall, setStall] = useState<StallKind | null>(null)
|
|
320
|
+
|
|
321
|
+
const lastMeaning = useRef('')
|
|
322
|
+
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (!enabled) return
|
|
325
|
+
// Captured now, checked on every tick: an interval is not cleared the
|
|
326
|
+
// instant the screen goes, and a poll that lands afterwards must not write
|
|
327
|
+
// a position into a screen that is no longer there.
|
|
328
|
+
const epoch = lifecycle.epoch
|
|
329
|
+
const id = setInterval(() => {
|
|
330
|
+
if (!lifecycle.accepts(epoch)) return
|
|
331
|
+
const snapshot = Media3.state()
|
|
332
|
+
// Position is not part of this. Replacing the state object on every tick
|
|
333
|
+
// re-renders the whole editor ten times a second to move a line, which is
|
|
334
|
+
// both wasteful and — because it lands at ten hertz — visibly steppy. The
|
|
335
|
+
// meaning of the state changes far less often than its numbers do, so
|
|
336
|
+
// only a change in meaning is worth a render, and a host that wants the
|
|
337
|
+
// position drives it from `positionUs()` on the UI thread.
|
|
338
|
+
const meaning = [
|
|
339
|
+
snapshot.playing,
|
|
340
|
+
snapshot.ready,
|
|
341
|
+
snapshot.buffering,
|
|
342
|
+
snapshot.ended,
|
|
343
|
+
snapshot.released,
|
|
344
|
+
snapshot.width,
|
|
345
|
+
snapshot.height,
|
|
346
|
+
snapshot.windowIndex,
|
|
347
|
+
snapshot.windowCount,
|
|
348
|
+
snapshot.errorCount,
|
|
349
|
+
snapshot.errorMessage,
|
|
350
|
+
snapshot.rateConstrained,
|
|
351
|
+
].join('|')
|
|
352
|
+
if (meaning !== lastMeaning.current) {
|
|
353
|
+
lastMeaning.current = meaning
|
|
354
|
+
setState(snapshot)
|
|
355
|
+
setPlaying(snapshot.playing)
|
|
356
|
+
}
|
|
357
|
+
const sample: RecoverySample = {
|
|
358
|
+
atMs: Date.now(),
|
|
359
|
+
positionUs: snapshot.windowPositionUs,
|
|
360
|
+
framesOffered: snapshot.framesOffered,
|
|
361
|
+
framesAvailable: snapshot.framesAvailable,
|
|
362
|
+
// Latched is not reported directly: what arrived, less what is still
|
|
363
|
+
// waiting, is the count the consumer has actually taken.
|
|
364
|
+
framesLatched: snapshot.framesAvailable - snapshot.pendingDepth,
|
|
365
|
+
framesRejected: snapshot.framesRejected,
|
|
366
|
+
pendingGeneration: snapshot.pendingGeneration,
|
|
367
|
+
consumerGeneration: surfaceGenerationRef.current,
|
|
368
|
+
}
|
|
369
|
+
const since = healthy.current
|
|
370
|
+
if (since === null) {
|
|
371
|
+
healthy.current = sample
|
|
372
|
+
} else if (classifyStall(since, sample) === null) {
|
|
373
|
+
// Healthy, or simply not running: either way this is the new baseline.
|
|
374
|
+
healthy.current = sample
|
|
375
|
+
if (stallRef.current !== null) setStall(null)
|
|
376
|
+
} else if (shouldRecover(since, sample)) {
|
|
377
|
+
const kind = classifyStall(since, sample)
|
|
378
|
+
healthy.current = sample
|
|
379
|
+
stallRef.current = kind
|
|
380
|
+
setStall(kind)
|
|
381
|
+
// Re-run the surface/playlist handshake. Never silently: a plausible
|
|
382
|
+
// audio-only result is the thing this refuses to leave alone.
|
|
383
|
+
session.current = { ...session.current, pending: true }
|
|
384
|
+
setRetry((n) => n + 1)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!snapshot.playing) return
|
|
388
|
+
const projectUs = windowToProjectTime(
|
|
389
|
+
itemsRef.current,
|
|
390
|
+
snapshot.windowIndex,
|
|
391
|
+
snapshot.windowPositionUs,
|
|
392
|
+
)
|
|
393
|
+
if (projectUs !== null) options.onPositionUs?.(projectUs)
|
|
394
|
+
}, pollMs)
|
|
395
|
+
return () => clearInterval(id)
|
|
396
|
+
// `onPositionUs` is read through the closure on each tick; depending on it
|
|
397
|
+
// would restart the interval whenever the host re-created the callback.
|
|
398
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
399
|
+
}, [enabled, pollMs, lifecycle, lifecycleEpoch])
|
|
400
|
+
|
|
401
|
+
const lastSeekRef = useRef<Media3Playback['lastSeek']>(null)
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Seek in **global** timeline time.
|
|
405
|
+
*
|
|
406
|
+
* The store's playhead is global and stays global; the conversion to a window
|
|
407
|
+
* and a position inside it happens here, once, through `projectTimeToWindow` —
|
|
408
|
+
* the same mapping `play` and the playlist load use. Handing a player a global
|
|
409
|
+
* time as though it were window-local is silent: it clamps to the end of
|
|
410
|
+
* whichever clip it is in and reports a perfectly plausible position.
|
|
411
|
+
*/
|
|
412
|
+
const seek = useCallback((timeUs: number) => {
|
|
413
|
+
const target = projectTimeToWindow(itemsRef.current, timeUs)
|
|
414
|
+
if (!target) return
|
|
415
|
+
lastSeekRef.current = {
|
|
416
|
+
globalUs: Math.round(timeUs),
|
|
417
|
+
windowIndex: target.windowIndex,
|
|
418
|
+
localUs: target.positionUs,
|
|
419
|
+
}
|
|
420
|
+
Media3.seekToWindow(target.windowIndex, target.positionUs)
|
|
421
|
+
}, [])
|
|
422
|
+
|
|
423
|
+
const positionUs = useCallback((): number | null => {
|
|
424
|
+
const snapshot = Media3.state()
|
|
425
|
+
if (snapshot.released || itemsRef.current.length === 0) return null
|
|
426
|
+
return windowToProjectTime(
|
|
427
|
+
itemsRef.current,
|
|
428
|
+
snapshot.windowIndex,
|
|
429
|
+
snapshot.windowPositionUs,
|
|
430
|
+
)
|
|
431
|
+
}, [])
|
|
432
|
+
|
|
433
|
+
const durationUsRef = useRef(0)
|
|
434
|
+
durationUsRef.current = projectDuration(project)
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Play, replaying from the start if there is nothing left to play.
|
|
438
|
+
*
|
|
439
|
+
* The decision is `playIntent` in `@pictor-sdk/video-core`, not an `if` here: it
|
|
440
|
+
* is the same question a host's own transport has to answer, and two answers
|
|
441
|
+
* would eventually disagree. Read fresh from the player rather than from
|
|
442
|
+
* `state`, which only re-renders on a change of meaning and can be a poll
|
|
443
|
+
* behind at the moment of the press.
|
|
444
|
+
*/
|
|
445
|
+
const play = useCallback(() => {
|
|
446
|
+
const snapshot = Media3.state()
|
|
447
|
+
const intent = playIntent({
|
|
448
|
+
positionUs:
|
|
449
|
+
windowToProjectTime(
|
|
450
|
+
itemsRef.current,
|
|
451
|
+
snapshot.windowIndex,
|
|
452
|
+
snapshot.windowPositionUs,
|
|
453
|
+
) ?? 0,
|
|
454
|
+
durationUs: durationUsRef.current,
|
|
455
|
+
playbackState: snapshot.playbackState,
|
|
456
|
+
ended: snapshot.ended,
|
|
457
|
+
})
|
|
458
|
+
if (intent.seekToUs !== null) {
|
|
459
|
+
const target = projectTimeToWindow(itemsRef.current, intent.seekToUs)
|
|
460
|
+
if (target) Media3.seekToWindow(target.windowIndex, target.positionUs)
|
|
461
|
+
}
|
|
462
|
+
if (!intent.play) return intent
|
|
463
|
+
// Refused once the screen has given the player back: a late press from a
|
|
464
|
+
// control that has not unmounted yet must not start a released player.
|
|
465
|
+
if (lifecycle.released) return intent
|
|
466
|
+
Media3.play()
|
|
467
|
+
setPlaying(true)
|
|
468
|
+
return intent
|
|
469
|
+
}, [lifecycle])
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
playing,
|
|
473
|
+
play,
|
|
474
|
+
get lastSeek() {
|
|
475
|
+
return lastSeekRef.current
|
|
476
|
+
},
|
|
477
|
+
positionUs,
|
|
478
|
+
effectiveRate: state?.effectiveRate ?? 1,
|
|
479
|
+
pause: useCallback(() => {
|
|
480
|
+
Media3.pause()
|
|
481
|
+
setPlaying(false)
|
|
482
|
+
}, []),
|
|
483
|
+
setRate: useCallback((rate: number) => Media3.setRate(rate), []),
|
|
484
|
+
seek,
|
|
485
|
+
state,
|
|
486
|
+
items,
|
|
487
|
+
unsupported: playlist.unsupported,
|
|
488
|
+
divergenceUs,
|
|
489
|
+
lifecycle,
|
|
490
|
+
stall,
|
|
491
|
+
}
|
|
492
|
+
}
|