@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,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
+ }