@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,82 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AudioMixProgram,
|
|
3
|
+
ExportSourceContext,
|
|
4
|
+
MediaSource,
|
|
5
|
+
VideoProject,
|
|
6
|
+
} from '@pictor-sdk/video-core'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Turning a compiled mix program's spans into files to decode.
|
|
10
|
+
*
|
|
11
|
+
* Shared by the exporter and by preview preparation, deliberately. The two must
|
|
12
|
+
* agree about which file a span means, or a preview would be mixed from one set
|
|
13
|
+
* of sources and the export from another — and the difference would only be
|
|
14
|
+
* audible in the finished video.
|
|
15
|
+
*
|
|
16
|
+
* @experimental
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a span is, so a host can resolve it as what it actually is.
|
|
21
|
+
*
|
|
22
|
+
* A span carved from a clip's own audio resolves as that clip — the same
|
|
23
|
+
* context the video pass used, so the same media resolves the same way. A span
|
|
24
|
+
* from a music item resolves as that item. The id alone cannot say which, and a
|
|
25
|
+
* host that guessed would resolve the first thing that matched.
|
|
26
|
+
*/
|
|
27
|
+
export function mixSpanContext(
|
|
28
|
+
project: VideoProject,
|
|
29
|
+
spanId: string,
|
|
30
|
+
): ExportSourceContext | null {
|
|
31
|
+
const isClip = project.clips.some((clip) => clip.id === spanId)
|
|
32
|
+
const isAudio = project.audio.some((item) => item.id === spanId)
|
|
33
|
+
// Ambiguous or absent are the same answer: there is no one owner to resolve
|
|
34
|
+
// against, and picking either would be a guess.
|
|
35
|
+
if (isClip === isAudio) return null
|
|
36
|
+
return isClip ? { kind: 'clip', clipId: spanId } : { kind: 'audio', audioId: spanId }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @experimental */
|
|
40
|
+
export interface ResolvedMixSources {
|
|
41
|
+
/** Span id to readable URI, for every span that needs decoding. */
|
|
42
|
+
uris: Record<string, string>
|
|
43
|
+
/** One sentence per span that could not be resolved. Empty means usable. */
|
|
44
|
+
unresolved: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve every span that needs decoding.
|
|
49
|
+
*
|
|
50
|
+
* Silent spans are skipped: they occupy their time and contribute no samples,
|
|
51
|
+
* so asking a host to resolve one would be asking for a file that is not
|
|
52
|
+
* needed — and a host that returned nothing would look like a failure.
|
|
53
|
+
*
|
|
54
|
+
* @experimental
|
|
55
|
+
*/
|
|
56
|
+
export function resolveMixSources(
|
|
57
|
+
project: VideoProject,
|
|
58
|
+
program: AudioMixProgram,
|
|
59
|
+
resolveSource: (source: MediaSource, context: ExportSourceContext) => string | null | undefined,
|
|
60
|
+
): ResolvedMixSources {
|
|
61
|
+
const uris: Record<string, string> = {}
|
|
62
|
+
const unresolved: string[] = []
|
|
63
|
+
|
|
64
|
+
for (const span of program.spans) {
|
|
65
|
+
if (span.silent || span.source === null) continue
|
|
66
|
+
const context = mixSpanContext(project, span.id)
|
|
67
|
+
if (context === null) {
|
|
68
|
+
unresolved.push(
|
|
69
|
+
`audio span '${span.id}' matches no clip or audio item, so it has no owner to resolve against`,
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const uri = resolveSource(span.source, context)
|
|
74
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
75
|
+
unresolved.push(`audio span '${span.id}' could not be resolved to a readable source`)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
uris[span.id] = uri
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { uris, unresolved }
|
|
82
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { requireNative } from './player'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where an export's overlay rasters are written.
|
|
5
|
+
*
|
|
6
|
+
* ## Why the adapter owns this
|
|
7
|
+
*
|
|
8
|
+
* `OverlayResources.kt`, in this package, is what reads these files back and
|
|
9
|
+
* checks their length and CRC. The writer belongs beside the reader: a host
|
|
10
|
+
* that produced the bytes *and* chose where they lived would need a filesystem
|
|
11
|
+
* dependency and a path convention, and the two ends of one format would be
|
|
12
|
+
* maintained apart.
|
|
13
|
+
*
|
|
14
|
+
* It also keeps the promise the rest of the library makes. `@pictor-sdk/video-core`,
|
|
15
|
+
* `@pictor-sdk/video-text` and the example app acquire no filesystem package for
|
|
16
|
+
* this; they receive a structural sink and never learn a path.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it is synchronous
|
|
19
|
+
*
|
|
20
|
+
* `OverlayRasterSink.stage` must return a descriptor before the renderer can
|
|
21
|
+
* continue building its preparation, and React Native has no synchronous
|
|
22
|
+
* filesystem. The bytes are written by a JSI host function, which runs on the
|
|
23
|
+
* calling thread and returns a value like any other function — no bridge round
|
|
24
|
+
* trip, no second event loop, no waiting on a promise that cannot be awaited.
|
|
25
|
+
*
|
|
26
|
+
* That is a real cost: the JS thread is blocked for the write. Measured on a
|
|
27
|
+
* Pixel 7, release build, at roughly 5 ms per megabyte:
|
|
28
|
+
*
|
|
29
|
+
* | raster | bytes | stall |
|
|
30
|
+
* |---------------|--------|----------|
|
|
31
|
+
* | 671 x 114 | 299 KB | 2 ms |
|
|
32
|
+
* | 1080 x 400 | 1.7 MB | 7 ms |
|
|
33
|
+
* | 1080 x 1920 | 8.1 MB | 36–48 ms |
|
|
34
|
+
* | 3240 x 5760 | 71 MB | 357 ms |
|
|
35
|
+
*
|
|
36
|
+
* The first three are the operating point: a raster covers the overlay's own
|
|
37
|
+
* bounds at export scale, not the project, and the two overlays in the Text
|
|
38
|
+
* acceptance document staged 426 KB and 306 KB. A full-bleed overlay in a
|
|
39
|
+
* 1080x1920 export is the realistic worst case at ~40 ms. The last row is the
|
|
40
|
+
* pessimistic bound — a project-sized raster at 3x — and it is what the note
|
|
41
|
+
* below is about.
|
|
42
|
+
*
|
|
43
|
+
* All of it is paid once per overlay during preparation, before the export
|
|
44
|
+
* begins and before there is a progress bar to stutter. It is not a per-frame
|
|
45
|
+
* cost. So the synchronous contract stands at these sizes; see
|
|
46
|
+
* `measureStagingStall` for when it should not.
|
|
47
|
+
*
|
|
48
|
+
* @experimental
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** What native returns for one staged raster. */
|
|
52
|
+
interface StagedDescriptor {
|
|
53
|
+
/** Opaque ownership handle. The only thing that may be released. */
|
|
54
|
+
token: string
|
|
55
|
+
/** Where it went. Read by the export reader; not shown to anyone. */
|
|
56
|
+
file: string
|
|
57
|
+
byteLength: number
|
|
58
|
+
checksum: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StagingHost {
|
|
62
|
+
stage(bytes: ArrayBuffer, width: number, height: number): StagedDescriptor
|
|
63
|
+
release(token: string): boolean
|
|
64
|
+
releaseAll(): number
|
|
65
|
+
sweep(): number
|
|
66
|
+
ledger(): { created: number; released: number; live: number }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare const global: { __pictorOverlayStaging?: StagingHost }
|
|
70
|
+
|
|
71
|
+
let installed = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Install the staging primitive, once.
|
|
75
|
+
*
|
|
76
|
+
* Idempotent, and safe to call before an export rather than at module load: a
|
|
77
|
+
* host that never exports an overlay never pays for it.
|
|
78
|
+
*/
|
|
79
|
+
export function installOverlayStaging(): boolean {
|
|
80
|
+
if (installed && global.__pictorOverlayStaging !== undefined) return true
|
|
81
|
+
installed = requireNative().installOverlayStaging()
|
|
82
|
+
return installed && global.__pictorOverlayStaging !== undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function host(): StagingHost {
|
|
86
|
+
const h = global.__pictorOverlayStaging
|
|
87
|
+
if (h === undefined) {
|
|
88
|
+
throw new Error('@pictor-sdk/video-android: overlay staging is not installed')
|
|
89
|
+
}
|
|
90
|
+
return h
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a sink hands back, plus the token that owns it. @experimental */
|
|
94
|
+
export interface StagedRaster {
|
|
95
|
+
file: string
|
|
96
|
+
byteLength: number
|
|
97
|
+
checksum: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A sink, and the tokens it is holding.
|
|
102
|
+
*
|
|
103
|
+
* The token list is the whole point of the wrapper: the structural
|
|
104
|
+
* `OverlayRasterSink` contract identifies a staged raster by its file, and a
|
|
105
|
+
* caller that released by path would be naming something it was never given
|
|
106
|
+
* authority over. Every release here goes through the token native issued.
|
|
107
|
+
*
|
|
108
|
+
* @experimental
|
|
109
|
+
*/
|
|
110
|
+
export interface OverlayStagingSink {
|
|
111
|
+
stage(
|
|
112
|
+
bytes: Uint8Array,
|
|
113
|
+
meta: { overlayId: string; resourceId: string; rasterWidth: number; rasterHeight: number },
|
|
114
|
+
): StagedRaster
|
|
115
|
+
/** Discard one staged raster, by the file the descriptor carried. */
|
|
116
|
+
remove(file: string): void
|
|
117
|
+
/** Discard everything this sink still holds. Idempotent. */
|
|
118
|
+
releaseAll(): number
|
|
119
|
+
/** For assertions: created, released and still live. */
|
|
120
|
+
ledger(): { created: number; released: number; live: number }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove sidecars a previous process left behind.
|
|
125
|
+
*
|
|
126
|
+
* Scoped by native to this module's own directory and file suffix, so it cannot
|
|
127
|
+
* take a cache file belonging to anything else. Returns how many it removed.
|
|
128
|
+
*
|
|
129
|
+
* @experimental
|
|
130
|
+
*/
|
|
131
|
+
export function sweepOverlayStaging(): number {
|
|
132
|
+
if (!installOverlayStaging()) return 0
|
|
133
|
+
return host().sweep()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @experimental */
|
|
137
|
+
export function createOverlayStagingSink(): OverlayStagingSink {
|
|
138
|
+
if (!installOverlayStaging()) {
|
|
139
|
+
throw new Error('@pictor-sdk/video-android: overlay staging could not be installed')
|
|
140
|
+
}
|
|
141
|
+
/** file → token, so a structural `remove(file)` can release exactly. */
|
|
142
|
+
const tokens = new Map<string, string>()
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
stage(bytes, meta) {
|
|
146
|
+
/*
|
|
147
|
+
A view over a larger buffer is copied rather than passed whole.
|
|
148
|
+
|
|
149
|
+
`Uint8Array` may be a window onto a bigger `ArrayBuffer`, and handing
|
|
150
|
+
native the underlying buffer would stage the whole thing — the right
|
|
151
|
+
length by accident when the view happens to cover it, and silently wrong
|
|
152
|
+
when it does not.
|
|
153
|
+
*/
|
|
154
|
+
const exact =
|
|
155
|
+
bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
156
|
+
? (bytes.buffer as ArrayBuffer)
|
|
157
|
+
: (bytes.slice().buffer as ArrayBuffer)
|
|
158
|
+
|
|
159
|
+
const staged = host().stage(exact, meta.rasterWidth, meta.rasterHeight)
|
|
160
|
+
tokens.set(staged.file, staged.token)
|
|
161
|
+
return {
|
|
162
|
+
file: staged.file,
|
|
163
|
+
byteLength: staged.byteLength,
|
|
164
|
+
checksum: staged.checksum,
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
remove(file) {
|
|
169
|
+
const token = tokens.get(file)
|
|
170
|
+
// Releasing something never staged, or already released, is not an error:
|
|
171
|
+
// the caller's cleanup paths overlap on purpose, and each must be safe to
|
|
172
|
+
// run after the others.
|
|
173
|
+
if (token === undefined) return
|
|
174
|
+
tokens.delete(file)
|
|
175
|
+
host().release(token)
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
releaseAll() {
|
|
179
|
+
tokens.clear()
|
|
180
|
+
return host().releaseAll()
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
ledger() {
|
|
184
|
+
return host().ledger()
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How long a synchronous stage blocks the JS thread, for a raster this size.
|
|
191
|
+
*
|
|
192
|
+
* Exists because the synchronous contract is only defensible if the number is
|
|
193
|
+
* small. If a maximum-size raster stalls long enough to drop frames in the
|
|
194
|
+
* export sheet, the answer is to make `OverlayRasterSink.stage` asynchronous —
|
|
195
|
+
* not to keep a blocking write behind a synchronous signature because it is
|
|
196
|
+
* convenient.
|
|
197
|
+
*
|
|
198
|
+
* @experimental
|
|
199
|
+
*/
|
|
200
|
+
export function measureStagingStall(
|
|
201
|
+
sink: OverlayStagingSink,
|
|
202
|
+
width: number,
|
|
203
|
+
height: number,
|
|
204
|
+
): { ms: number; bytes: number } {
|
|
205
|
+
const bytes = new Uint8Array(width * height * 4)
|
|
206
|
+
const at = Date.now()
|
|
207
|
+
const staged = sink.stage(bytes, {
|
|
208
|
+
overlayId: 'measure',
|
|
209
|
+
resourceId: 'measure',
|
|
210
|
+
rasterWidth: width,
|
|
211
|
+
rasterHeight: height,
|
|
212
|
+
})
|
|
213
|
+
const ms = Date.now() - at
|
|
214
|
+
sink.remove(staged.file)
|
|
215
|
+
return { ms, bytes: bytes.byteLength }
|
|
216
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns playback, and until when.
|
|
3
|
+
*
|
|
4
|
+
* Media3's player is a **process-wide singleton**. Nothing about it ends when
|
|
5
|
+
* the screen that started it goes away, so on a Pixel 7 leaving the editor while
|
|
6
|
+
* playing left the audio running, and backgrounding the app did the same: the
|
|
7
|
+
* picture stopped because its surface was retired, and the sound carried on
|
|
8
|
+
* because nobody had told the player anything.
|
|
9
|
+
*
|
|
10
|
+
* A singleton with no owner is the whole problem, so this gives it one. A screen
|
|
11
|
+
* takes ownership when it mounts and gives it back exactly once — and a screen
|
|
12
|
+
* that mounts afterwards starts from nothing rather than inheriting whatever the
|
|
13
|
+
* last one was doing.
|
|
14
|
+
*
|
|
15
|
+
* ## Two kinds of stopping, deliberately different
|
|
16
|
+
*
|
|
17
|
+
* **Suspend** is temporary: the app went to the background, or the picker came
|
|
18
|
+
* up over the editor. Playback pauses and the player is kept, because the screen
|
|
19
|
+
* is coming back and rebuilding a player it never lost would be a stall the user
|
|
20
|
+
* did nothing to earn.
|
|
21
|
+
*
|
|
22
|
+
* **Teardown** is final: the screen unmounted, or navigation left it. The player
|
|
23
|
+
* is paused *and then* released, in that order, so nothing is audible in the
|
|
24
|
+
* gap. After a teardown this object does nothing at all — a late suspend from a
|
|
25
|
+
* listener that has not been removed yet must not resurrect anything.
|
|
26
|
+
*
|
|
27
|
+
* Neither is a reason to touch the document. Playback stopping says nothing
|
|
28
|
+
* about what the project is or where its playhead should be, and clearing either
|
|
29
|
+
* would turn leaving a screen into an edit.
|
|
30
|
+
*
|
|
31
|
+
* ## Epochs
|
|
32
|
+
*
|
|
33
|
+
* Every stop bumps an epoch, and callbacks carry the epoch they were scheduled
|
|
34
|
+
* under. A poll that fires after the screen backgrounded still runs — clearing
|
|
35
|
+
* an interval is not instantaneous — and `accepts` is how it finds out that the
|
|
36
|
+
* answer it is about to deliver is no longer wanted.
|
|
37
|
+
*
|
|
38
|
+
* @experimental
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Why playback stopped. Recorded so a log can say which path ran. @experimental */
|
|
42
|
+
export type PlaybackStopReason = 'unmount' | 'background' | 'blur'
|
|
43
|
+
|
|
44
|
+
/** @experimental */
|
|
45
|
+
export interface PlaybackLifecycleEffects {
|
|
46
|
+
/** Stop the sound. Must be safe to call when already paused. */
|
|
47
|
+
pause(): void
|
|
48
|
+
/** Give up the native player, and with it audio focus. */
|
|
49
|
+
release(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @experimental */
|
|
53
|
+
export interface PlaybackLifecycle {
|
|
54
|
+
/** Whether this screen currently owns running playback. */
|
|
55
|
+
readonly active: boolean
|
|
56
|
+
/** Whether the player has been given back. Final. */
|
|
57
|
+
readonly released: boolean
|
|
58
|
+
/** Why it last stopped, or `null`. */
|
|
59
|
+
readonly reason: PlaybackStopReason | null
|
|
60
|
+
/** Bumped by every stop; callbacks carry the value they were scheduled under. */
|
|
61
|
+
readonly epoch: number
|
|
62
|
+
/** Whether work scheduled under `epoch` may still act. */
|
|
63
|
+
accepts(epoch: number): boolean
|
|
64
|
+
/** Pause and keep the player. Idempotent within one suspension. */
|
|
65
|
+
suspend(reason: PlaybackStopReason): void
|
|
66
|
+
/** Become active again. Idempotent; ignored after teardown. */
|
|
67
|
+
resume(): void
|
|
68
|
+
/** Pause, then release, exactly once, forever. */
|
|
69
|
+
teardown(reason: PlaybackStopReason): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @experimental */
|
|
73
|
+
export function createPlaybackLifecycle(
|
|
74
|
+
effects: PlaybackLifecycleEffects,
|
|
75
|
+
): PlaybackLifecycle {
|
|
76
|
+
let active = true
|
|
77
|
+
let released = false
|
|
78
|
+
let reason: PlaybackStopReason | null = null
|
|
79
|
+
let epoch = 1
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
get active() {
|
|
83
|
+
return active
|
|
84
|
+
},
|
|
85
|
+
get released() {
|
|
86
|
+
return released
|
|
87
|
+
},
|
|
88
|
+
get reason() {
|
|
89
|
+
return reason
|
|
90
|
+
},
|
|
91
|
+
get epoch() {
|
|
92
|
+
return epoch
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
accepts(at) {
|
|
96
|
+
// Never after teardown, whatever epoch is offered: the player is gone and
|
|
97
|
+
// there is nothing a late callback could correctly do with it.
|
|
98
|
+
if (released) return false
|
|
99
|
+
return active && at === epoch
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
suspend(why) {
|
|
103
|
+
if (released || !active) return
|
|
104
|
+
active = false
|
|
105
|
+
reason = why
|
|
106
|
+
epoch += 1
|
|
107
|
+
effects.pause()
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
resume() {
|
|
111
|
+
if (released || active) return
|
|
112
|
+
active = true
|
|
113
|
+
reason = null
|
|
114
|
+
epoch += 1
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
teardown(why) {
|
|
118
|
+
if (released) return
|
|
119
|
+
const wasActive = active
|
|
120
|
+
released = true
|
|
121
|
+
active = false
|
|
122
|
+
reason = why
|
|
123
|
+
epoch += 1
|
|
124
|
+
/*
|
|
125
|
+
Pause first, then release — and only if it was still running.
|
|
126
|
+
|
|
127
|
+
Releasing a playing player is the one ordering that can be heard: the
|
|
128
|
+
release is not instantaneous, and the gap between "still playing" and
|
|
129
|
+
"gone" is exactly the audio that outlived the screen. A screen torn down
|
|
130
|
+
while already suspended was paused on the way in, so pausing it again
|
|
131
|
+
would be a native call that says nothing.
|
|
132
|
+
*/
|
|
133
|
+
if (wasActive) effects.pause()
|
|
134
|
+
effects.release()
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/player.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching the native adapter.
|
|
3
|
+
*
|
|
4
|
+
* This file used to hold `AndroidVideoPlayer`: a full player over `MediaExtractor`,
|
|
5
|
+
* `MediaCodec`, `AudioTrack` and an `ImageReader` pool of `AHardwareBuffer`s.
|
|
6
|
+
* That path reached one-frame synchronisation and is gone. Media3's ExoPlayer
|
|
7
|
+
* rendering into the persistent external-OES `SurfaceTexture` replaced it, for
|
|
8
|
+
* the reasons in `docs/research/media3-mapping.md` — codec pacing, buffer
|
|
9
|
+
* ownership, async-flush races and per-device workarounds are ExoPlayer's
|
|
10
|
+
* problem to have already solved, not Pictor's to solve again.
|
|
11
|
+
*
|
|
12
|
+
* What remains is the module accessor and the one call that is not playback:
|
|
13
|
+
* publishing the presentation surface.
|
|
14
|
+
*
|
|
15
|
+
* @experimental
|
|
16
|
+
*/
|
|
17
|
+
import Native from './NativePictorVideo'
|
|
18
|
+
|
|
19
|
+
/** @experimental */
|
|
20
|
+
export const AndroidVideoPlayer = {
|
|
21
|
+
/**
|
|
22
|
+
* Publish the presentation surface host object on the JS runtime.
|
|
23
|
+
*
|
|
24
|
+
* It is captured into a worklet closure from there; the UI runtime has its
|
|
25
|
+
* own global and does not see this one.
|
|
26
|
+
*/
|
|
27
|
+
installVideoSurface(): boolean {
|
|
28
|
+
return requireNative().installVideoSurface()
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function requireNative() {
|
|
33
|
+
if (!Native) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'@pictor-sdk/video-android: the native module is not installed. It is Android-only and requires minSdkVersion 29.',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return Native
|
|
39
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which playlist Media3 is holding, and against which surface.
|
|
3
|
+
*
|
|
4
|
+
* The bug this exists to prevent, in order:
|
|
5
|
+
*
|
|
6
|
+
* 1. The editor opens and builds surface A.
|
|
7
|
+
* 2. The system picker opens. The app backgrounds; A is no longer usable.
|
|
8
|
+
* 3. Two clips are added to the document while the picker is closing.
|
|
9
|
+
* 4. The editor returns and builds surface B.
|
|
10
|
+
* 5. Nothing prepares, because the only thing that ever triggered preparation
|
|
11
|
+
* was the *playlist changing*, and it changed in step 3 — when there was no
|
|
12
|
+
* surface to prepare against. `setPlaylist` was refused, the signature was
|
|
13
|
+
* recorded as applied anyway, and the editor sat there with two clips on the
|
|
14
|
+
* timeline and a player holding nothing.
|
|
15
|
+
*
|
|
16
|
+
* Two corrections follow from that. Readiness is **level-triggered**: the
|
|
17
|
+
* question is "does the playlist Media3 holds match the document, against the
|
|
18
|
+
* surface that is live *now*", asked whenever either side moves — not "did the
|
|
19
|
+
* playlist just change". And a refusal is **not** recorded as applied, so it is
|
|
20
|
+
* retried rather than remembered as done.
|
|
21
|
+
*
|
|
22
|
+
* A generation is what makes "the surface that is live now" expressible. Without
|
|
23
|
+
* it a caller can only know that *a* surface exists, which cannot distinguish
|
|
24
|
+
* the one it prepared against from the one that replaced it.
|
|
25
|
+
*
|
|
26
|
+
* Pure, so the sequence above is a test rather than a device run.
|
|
27
|
+
*
|
|
28
|
+
* @experimental
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** No surface. Distinct from every real generation, which start at 1. */
|
|
32
|
+
export const NO_SURFACE = 0
|
|
33
|
+
|
|
34
|
+
/** @experimental */
|
|
35
|
+
export interface PlaylistSession {
|
|
36
|
+
/** The playlist Media3 is holding, or null if it holds none. */
|
|
37
|
+
appliedSignature: string | null
|
|
38
|
+
/** The surface generation it was prepared against. */
|
|
39
|
+
appliedGeneration: number
|
|
40
|
+
/**
|
|
41
|
+
* A preparation was attempted and refused.
|
|
42
|
+
*
|
|
43
|
+
* Kept so the retry is driven by this rather than by hoping the document
|
|
44
|
+
* changes again. A user who has added their clips will not add them twice.
|
|
45
|
+
*/
|
|
46
|
+
pending: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @experimental */
|
|
50
|
+
export function idlePlaylistSession(): PlaylistSession {
|
|
51
|
+
return { appliedSignature: null, appliedGeneration: NO_SURFACE, pending: false }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the document and the device currently want. @experimental */
|
|
55
|
+
export interface PlaylistIntent {
|
|
56
|
+
/** A stable description of the playlist the document implies. */
|
|
57
|
+
signature: string
|
|
58
|
+
/** The live surface generation, or {@link NO_SURFACE}. */
|
|
59
|
+
generation: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether to call `setPlaylist` now.
|
|
64
|
+
*
|
|
65
|
+
* @experimental
|
|
66
|
+
*/
|
|
67
|
+
export function shouldPrepare(session: PlaylistSession, intent: PlaylistIntent): boolean {
|
|
68
|
+
// Nothing to prepare against. Attempting it is the refusal that started all
|
|
69
|
+
// of this, and it would poison the session by recording a signature.
|
|
70
|
+
if (intent.generation === NO_SURFACE) return false
|
|
71
|
+
if (session.pending) return true
|
|
72
|
+
if (session.appliedSignature !== intent.signature) return true
|
|
73
|
+
// The playlist is unchanged but the surface underneath it is not. A player
|
|
74
|
+
// attached to a retired surface decodes, releases frames and reports no
|
|
75
|
+
// error while the live texture receives nothing.
|
|
76
|
+
return session.appliedGeneration !== intent.generation
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Record the outcome.
|
|
81
|
+
*
|
|
82
|
+
* A refusal leaves the session **unchanged except for `pending`**. Recording the
|
|
83
|
+
* signature on failure is precisely the mistake that made the editor wait for an
|
|
84
|
+
* edit that was never going to come.
|
|
85
|
+
*
|
|
86
|
+
* @experimental
|
|
87
|
+
*/
|
|
88
|
+
export function afterPrepare(
|
|
89
|
+
session: PlaylistSession,
|
|
90
|
+
intent: PlaylistIntent,
|
|
91
|
+
ok: boolean,
|
|
92
|
+
): PlaylistSession {
|
|
93
|
+
if (!ok) return { ...session, pending: true }
|
|
94
|
+
return {
|
|
95
|
+
appliedSignature: intent.signature,
|
|
96
|
+
appliedGeneration: intent.generation,
|
|
97
|
+
pending: false,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The surface went away — backgrounded, replaced, released.
|
|
103
|
+
*
|
|
104
|
+
* The signature is kept: it still describes what Media3 was asked to hold, and
|
|
105
|
+
* the comparison that matters next is against the *generation*, which is now
|
|
106
|
+
* guaranteed to differ from any live one.
|
|
107
|
+
*
|
|
108
|
+
* @experimental
|
|
109
|
+
*/
|
|
110
|
+
export function retireSurface(session: PlaylistSession): PlaylistSession {
|
|
111
|
+
return { ...session, appliedGeneration: NO_SURFACE, pending: true }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a callback carrying `generation` is still worth acting on.
|
|
116
|
+
*
|
|
117
|
+
* A screen's teardown routinely runs after the next screen's setup, and an
|
|
118
|
+
* asynchronous surface creation can land after the surface it created has been
|
|
119
|
+
* replaced. Both arrive looking exactly like a valid result.
|
|
120
|
+
*
|
|
121
|
+
* @experimental
|
|
122
|
+
*/
|
|
123
|
+
export function isCurrentGeneration(generation: number, live: number): boolean {
|
|
124
|
+
return generation !== NO_SURFACE && generation === live
|
|
125
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One isolation control for the preview-audio hook, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* ## What it is for
|
|
5
|
+
*
|
|
6
|
+
* The picture stops advancing when a mix is loaded, and the minimal failing
|
|
7
|
+
* configuration loads a *cached* mix — no decode, no encode, no codecs at all.
|
|
8
|
+
* What is left in common is this hook being active, and being active means two
|
|
9
|
+
* separable things: the native calls it makes, and the React state it
|
|
10
|
+
* publishes. Five `useState` values re-render the editor while a surface, a
|
|
11
|
+
* texture consumer and a composition callback are all being re-established.
|
|
12
|
+
*
|
|
13
|
+
* Suppressing the publications while leaving every native call in place
|
|
14
|
+
* separates those two. If the freeze survives suppression it belongs to the
|
|
15
|
+
* native side; if it disappears, the trigger is a render-driven ownership
|
|
16
|
+
* transition and the fix belongs in whatever that render disturbs.
|
|
17
|
+
*
|
|
18
|
+
* ## What it is not
|
|
19
|
+
*
|
|
20
|
+
* Not a fix, and not reachable from a production build. Suppressed, the hook
|
|
21
|
+
* cannot report readiness, so audio never plays — the control is deliberately
|
|
22
|
+
* useless for anything except attribution. Anyone tempted to leave it on to
|
|
23
|
+
* "reduce renders" is turning a diagnostic into a silent feature regression.
|
|
24
|
+
*
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @experimental */
|
|
29
|
+
export interface PreviewAudioDiagnostics {
|
|
30
|
+
/** Drop the hook's React state publications, keeping every native call. */
|
|
31
|
+
suppressPublications: boolean
|
|
32
|
+
/** Log each publication with its value. Off by default; noisy under a mix. */
|
|
33
|
+
logPublications: boolean
|
|
34
|
+
/** Publications made, and publications suppressed. */
|
|
35
|
+
published: number
|
|
36
|
+
suppressed: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @experimental */
|
|
40
|
+
export const previewAudioDiagnostics: PreviewAudioDiagnostics = {
|
|
41
|
+
suppressPublications: false,
|
|
42
|
+
logPublications: false,
|
|
43
|
+
published: 0,
|
|
44
|
+
suppressed: 0,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wrap a setter so it can be observed and, under the control, withheld.
|
|
49
|
+
*
|
|
50
|
+
* Takes the name rather than deriving one, because a minified build would
|
|
51
|
+
* otherwise label every publication `t`.
|
|
52
|
+
*
|
|
53
|
+
* @experimental
|
|
54
|
+
*/
|
|
55
|
+
export function publishState<T>(name: string, set: (value: T) => void): (value: T) => void {
|
|
56
|
+
return (value: T) => {
|
|
57
|
+
if (previewAudioDiagnostics.suppressPublications) {
|
|
58
|
+
previewAudioDiagnostics.suppressed += 1
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
previewAudioDiagnostics.published += 1
|
|
62
|
+
if (previewAudioDiagnostics.logPublications) {
|
|
63
|
+
console.log(`AUDIOPUB ${name} ${JSON.stringify(value ?? null)}`)
|
|
64
|
+
}
|
|
65
|
+
set(value)
|
|
66
|
+
}
|
|
67
|
+
}
|