@pictor-sdk/video-android 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { playlistOf, type VideoProject } from '@pictor-sdk/video-core'
|
|
2
|
+
import { requireNative } from './player'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shared-clock playback spike, from the host's side.
|
|
6
|
+
*
|
|
7
|
+
* ## What it is testing
|
|
8
|
+
*
|
|
9
|
+
* Whether one playback-clock authority can drive the picture and a prepared,
|
|
10
|
+
* project-wide mix at once. The two-player preview cannot meet the 60 ms gate
|
|
11
|
+
* and the reasons are structural: anchors polled at 50 ms make a single
|
|
12
|
+
* comparison uncertain by up to ±56 ms against a 60 ms tolerance, the
|
|
13
|
+
* correction loop runs at 500 ms while each corrective seek costs 200-400 ms,
|
|
14
|
+
* and at a seam the project clock divides by a speed the player applies about
|
|
15
|
+
* 450 ms later.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the spec is built here
|
|
18
|
+
*
|
|
19
|
+
* `playlistOf` is the shared compiler, and it already decides every number this
|
|
20
|
+
* needs: the source bounds a clip plays between, its speed, and where the
|
|
21
|
+
* window sits on the project timeline. Passing its output through verbatim is
|
|
22
|
+
* what keeps the adapter free of a second interpretation of the document —
|
|
23
|
+
* native maps items onto media items and nothing else.
|
|
24
|
+
*
|
|
25
|
+
* Diagnostics-only, and it says nothing about live PCM gain or fades: it plays
|
|
26
|
+
* the same prepared mix file the current preview plays.
|
|
27
|
+
*
|
|
28
|
+
* @experimental
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** One clip, as the compiler described it. @experimental */
|
|
32
|
+
export interface CompositionSpikeItem {
|
|
33
|
+
uri: string
|
|
34
|
+
/** Clipping bounds in **source** time, which is where speed is applied. */
|
|
35
|
+
startUs: number
|
|
36
|
+
endUs: number
|
|
37
|
+
speed: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @experimental */
|
|
41
|
+
export interface CompositionSpikeSpec {
|
|
42
|
+
/**
|
|
43
|
+
* Hold the shared clock at each clip boundary.
|
|
44
|
+
*
|
|
45
|
+
* Off, and measured off for a reason: with `playWhenReady` false
|
|
46
|
+
* `CompositionPlayer` does not advance across an item boundary, so the
|
|
47
|
+
* incoming frame the hold waits for is never produced and the hold runs to
|
|
48
|
+
* its bound. Holding the clock and preparing the next stream are mutually
|
|
49
|
+
* exclusive on this player.
|
|
50
|
+
*/
|
|
51
|
+
seamHold?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Prepare the incoming item with an explicit seek while the clock is held.
|
|
54
|
+
*
|
|
55
|
+
* Only meaningful with `seamHold`. Pausing natural traversal and seeking to a
|
|
56
|
+
* target are different operations: the seek path resets the renderers, which
|
|
57
|
+
* is what makes a paused player produce a frame, and is the same path the
|
|
58
|
+
* existing seek readiness already passes on.
|
|
59
|
+
*/
|
|
60
|
+
seamSeek?: boolean
|
|
61
|
+
items: CompositionSpikeItem[]
|
|
62
|
+
/** The prepared mix. Empty plays the picture with no sound at all. */
|
|
63
|
+
mixUri: string
|
|
64
|
+
/**
|
|
65
|
+
* The mix's duration.
|
|
66
|
+
*
|
|
67
|
+
* Required whenever `mixUri` is set: the player computes each sequence's
|
|
68
|
+
* duration before playing, and an audio-only item cannot report one itself.
|
|
69
|
+
*/
|
|
70
|
+
mixDurationUs: number
|
|
71
|
+
width: number
|
|
72
|
+
height: number
|
|
73
|
+
/**
|
|
74
|
+
* The request this attachment belongs to.
|
|
75
|
+
*
|
|
76
|
+
* Claimed before preparation starts. Native refuses an attachment whose id is
|
|
77
|
+
* older than the newest claim, so a preparation that finishes late cannot put
|
|
78
|
+
* a stale mix on screen for even one frame. Omit for runs with no race.
|
|
79
|
+
*/
|
|
80
|
+
requestId?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @experimental */
|
|
84
|
+
export interface CompositionSpikeState {
|
|
85
|
+
ok: boolean
|
|
86
|
+
attached: boolean
|
|
87
|
+
built: number
|
|
88
|
+
released: number
|
|
89
|
+
error: string | null
|
|
90
|
+
/**
|
|
91
|
+
* Readiness latency, reported apart from synchronisation.
|
|
92
|
+
*
|
|
93
|
+
* A preparation pause is a latency figure and an A/V offset is an error;
|
|
94
|
+
* averaging one into the other is how a slow start reads as drift.
|
|
95
|
+
*/
|
|
96
|
+
startupMs: number
|
|
97
|
+
seekMs: number
|
|
98
|
+
/** What the person asked for, independent of whether the transport is running. */
|
|
99
|
+
intentPlaying?: boolean
|
|
100
|
+
/** A hold waiting for the first frame of the current epoch. */
|
|
101
|
+
releasePending?: boolean
|
|
102
|
+
releases?: number
|
|
103
|
+
readinessTimeouts?: number
|
|
104
|
+
readinessFailure?: string | null
|
|
105
|
+
/** How long the last hold lasted before playback was released. */
|
|
106
|
+
holdMs?: number
|
|
107
|
+
/**
|
|
108
|
+
* Wall-clock milliseconds spent holding the clock at each clip boundary.
|
|
109
|
+
*
|
|
110
|
+
* Listed, never averaged. Each entry is a pause the person sees: the decoder
|
|
111
|
+
* is torn down and rebuilt at a boundary and the clock waits rather than the
|
|
112
|
+
* picture falling behind it. **This is not seamless playback**, which is why
|
|
113
|
+
* it is reported apart from every synchronisation figure.
|
|
114
|
+
*/
|
|
115
|
+
seamHoldMs?: number[]
|
|
116
|
+
/** Holds that reached their bound without a correctly associated frame. */
|
|
117
|
+
seamHoldTimeouts?: number
|
|
118
|
+
seamHoldActive?: boolean
|
|
119
|
+
atNs?: number
|
|
120
|
+
positionUs?: number
|
|
121
|
+
playing?: boolean
|
|
122
|
+
playbackState?: number
|
|
123
|
+
durationUs?: number
|
|
124
|
+
presentedUs?: number | null
|
|
125
|
+
/** How old the presented frame was when it was read, so a stalled sample can be excluded. */
|
|
126
|
+
presentedAgeNs?: number
|
|
127
|
+
/** How long the paired read itself took. Bounds the pairing error. */
|
|
128
|
+
readSpanNs?: number
|
|
129
|
+
/** How stale the pairing was when it was read out. */
|
|
130
|
+
pairingAgeNs?: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the spike's spec from a project and a prepared mix.
|
|
135
|
+
*
|
|
136
|
+
* Unsupported clips are not silently dropped: `playlistOf` reports them, and a
|
|
137
|
+
* spec built from a playlist missing a window would put every later seam in the
|
|
138
|
+
* wrong place — which is exactly the class of error this spike exists to
|
|
139
|
+
* measure.
|
|
140
|
+
*
|
|
141
|
+
* @experimental
|
|
142
|
+
*/
|
|
143
|
+
export function compositionSpikeSpec(
|
|
144
|
+
project: VideoProject,
|
|
145
|
+
mix: { uri: string; durationUs: number },
|
|
146
|
+
resolve: (source: { kind: string; uri?: string }) => string | null,
|
|
147
|
+
): { spec: CompositionSpikeSpec; unsupported: string[] } {
|
|
148
|
+
const playlist = playlistOf(project)
|
|
149
|
+
const items: CompositionSpikeItem[] = []
|
|
150
|
+
const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`)
|
|
151
|
+
for (const item of playlist.items) {
|
|
152
|
+
const uri = resolve(item.source as unknown as { kind: string; uri?: string })
|
|
153
|
+
if (uri === null) {
|
|
154
|
+
unsupported.push(`${item.clipId}: unresolved`)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
items.push({
|
|
158
|
+
uri,
|
|
159
|
+
startUs: Number(item.startUs),
|
|
160
|
+
endUs: Number(item.endUs),
|
|
161
|
+
speed: item.speed,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
spec: {
|
|
166
|
+
items,
|
|
167
|
+
mixUri: mix.uri,
|
|
168
|
+
mixDurationUs: mix.durationUs,
|
|
169
|
+
width: project.size.width,
|
|
170
|
+
height: project.size.height,
|
|
171
|
+
},
|
|
172
|
+
unsupported,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function call(fn: () => string, label: string): Record<string, unknown> {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(fn()) as Record<string, unknown>
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, error: `${label}: ${String(error)}` }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Claim the next request id, before preparing. @experimental */
|
|
185
|
+
export function spikeClaim(): number {
|
|
186
|
+
const result = call(() => requireNative().spikeClaim(), 'spikeClaim')
|
|
187
|
+
return typeof result.requestId === 'number' ? result.requestId : 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @experimental */
|
|
191
|
+
export function spikeStart(spec: CompositionSpikeSpec): Record<string, unknown> {
|
|
192
|
+
return call(() => requireNative().spikeStart(JSON.stringify(spec)), 'spikeStart')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @experimental */
|
|
196
|
+
export function spikePlay(): Record<string, unknown> {
|
|
197
|
+
return call(() => requireNative().spikePlay(), 'spikePlay')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @experimental */
|
|
201
|
+
export function spikePause(): Record<string, unknown> {
|
|
202
|
+
return call(() => requireNative().spikePause(), 'spikePause')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @experimental */
|
|
206
|
+
export function spikeSeek(positionUs: number): Record<string, unknown> {
|
|
207
|
+
return call(() => requireNative().spikeSeek(positionUs), 'spikeSeek')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @experimental */
|
|
211
|
+
export function spikeState(): CompositionSpikeState {
|
|
212
|
+
return call(() => requireNative().spikeState(), 'spikeState') as unknown as CompositionSpikeState
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* One retained sample, unpacked.
|
|
217
|
+
*
|
|
218
|
+
* Packed across the boundary because a gate reads thousands of these, and named
|
|
219
|
+
* here because a gate that indexes into an array of eighteen longs is one
|
|
220
|
+
* transposition away from a confident wrong answer.
|
|
221
|
+
*
|
|
222
|
+
* @experimental
|
|
223
|
+
*/
|
|
224
|
+
export interface SpikeSample {
|
|
225
|
+
seq: number
|
|
226
|
+
atNs: number
|
|
227
|
+
positionUs: number
|
|
228
|
+
playing: boolean
|
|
229
|
+
ready: boolean
|
|
230
|
+
epoch: number
|
|
231
|
+
sinceEpochNs: number
|
|
232
|
+
frameId: number
|
|
233
|
+
frameGeneration: number
|
|
234
|
+
frameWindow: number
|
|
235
|
+
framePtsUs: number
|
|
236
|
+
/**
|
|
237
|
+
* When Media3's renderer said this frame was about to go to the surface.
|
|
238
|
+
*
|
|
239
|
+
* The earliest boundary observable here, and the one that separates the
|
|
240
|
+
* owners of a gap: nothing before this is the decoder or the item's
|
|
241
|
+
* preparation, this to {@link SpikeSample.frameAvailableNs} is the video
|
|
242
|
+
* graph, and that to {@link SpikeSample.frameConsumedNs} is this consumer.
|
|
243
|
+
*/
|
|
244
|
+
frameMetadataNs: number
|
|
245
|
+
/** The time Media3 *asked* the surface to present at. An intention. */
|
|
246
|
+
frameReleaseNs: number
|
|
247
|
+
/** The time the consumer latched it. The last boundary observable here. */
|
|
248
|
+
frameConsumedNs: number
|
|
249
|
+
frameAvailableNs: number
|
|
250
|
+
coalesced: number
|
|
251
|
+
offered: number
|
|
252
|
+
available: number
|
|
253
|
+
taken: number
|
|
254
|
+
readSpanNs: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @experimental */
|
|
258
|
+
export function spikeDrain(fromSeq: number, limit = 400): {
|
|
259
|
+
ok: boolean
|
|
260
|
+
rows: SpikeSample[]
|
|
261
|
+
nextSeq: number
|
|
262
|
+
total: number
|
|
263
|
+
dropped: number
|
|
264
|
+
} {
|
|
265
|
+
const page = call(() => requireNative().spikeDrain(fromSeq, limit), 'spikeDrain') as {
|
|
266
|
+
ok?: boolean
|
|
267
|
+
rows?: number[][]
|
|
268
|
+
nextSeq?: number
|
|
269
|
+
total?: number
|
|
270
|
+
dropped?: number
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ok: page.ok === true,
|
|
274
|
+
rows: (page.rows ?? []).map((r) => ({
|
|
275
|
+
seq: r[0]!,
|
|
276
|
+
atNs: r[1]!,
|
|
277
|
+
positionUs: r[2]!,
|
|
278
|
+
playing: (r[3]! & 1) === 1,
|
|
279
|
+
ready: (r[3]! & 2) === 2,
|
|
280
|
+
epoch: r[4]!,
|
|
281
|
+
sinceEpochNs: r[5]!,
|
|
282
|
+
frameId: r[6]!,
|
|
283
|
+
frameGeneration: r[7]!,
|
|
284
|
+
frameWindow: r[8]!,
|
|
285
|
+
framePtsUs: r[9]!,
|
|
286
|
+
frameReleaseNs: r[10]!,
|
|
287
|
+
frameConsumedNs: r[11]!,
|
|
288
|
+
frameAvailableNs: r[12]!,
|
|
289
|
+
coalesced: r[13]!,
|
|
290
|
+
offered: r[14]!,
|
|
291
|
+
available: r[15]!,
|
|
292
|
+
taken: r[16]!,
|
|
293
|
+
readSpanNs: r[17]!,
|
|
294
|
+
frameMetadataNs: r[18] ?? -1,
|
|
295
|
+
})),
|
|
296
|
+
nextSeq: page.nextSeq ?? fromSeq,
|
|
297
|
+
total: page.total ?? 0,
|
|
298
|
+
dropped: page.dropped ?? 0,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @experimental */
|
|
303
|
+
export function spikeRelease(): Record<string, unknown> {
|
|
304
|
+
return call(() => requireNative().spikeRelease(), 'spikeRelease')
|
|
305
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding when playback has settled, instead of assuming it after a timeout.
|
|
3
|
+
*
|
|
4
|
+
* The first attempt excluded a fixed 1.5 s after every transition. That is a
|
|
5
|
+
* guess in both directions: too short for a seek, whose tail leaked into the
|
|
6
|
+
* steady figures, and too long for a rate change that settles immediately. It
|
|
7
|
+
* also hides the thing worth knowing — how long recovery actually took.
|
|
8
|
+
*
|
|
9
|
+
* So a transition opens a phase, and the phase closes when the offsets say so:
|
|
10
|
+
* [CONVERGED_RUN] consecutive samples inside the expected bound. The recovery
|
|
11
|
+
* duration and the worst offset seen on the way are kept, because a transient
|
|
12
|
+
* that is excluded but not reported is a failure nobody can see.
|
|
13
|
+
*
|
|
14
|
+
* [MAX_RECOVERY_SAMPLES] is a safety net, not a policy. A phase that never
|
|
15
|
+
* converges is closed anyway and marked, so a permanent failure surfaces as a
|
|
16
|
+
* failure instead of an ever-growing exclusion window.
|
|
17
|
+
*
|
|
18
|
+
* Pure, so the rule is tested rather than trusted.
|
|
19
|
+
*
|
|
20
|
+
* @experimental
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What kind of transition opened a phase. Steady is the absence of one. */
|
|
24
|
+
export type Phase =
|
|
25
|
+
| 'steady'
|
|
26
|
+
| 'startup'
|
|
27
|
+
| 'seek'
|
|
28
|
+
| 'source'
|
|
29
|
+
| 'pauseResume'
|
|
30
|
+
| 'rateChange'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Consecutive in-bound samples that end a transient.
|
|
34
|
+
*
|
|
35
|
+
* Eight at 30 fps is about a quarter second of agreement — long enough that a
|
|
36
|
+
* single lucky frame cannot close a phase, short enough to measure recovery
|
|
37
|
+
* rather than round it up.
|
|
38
|
+
*/
|
|
39
|
+
export const CONVERGED_RUN = 8
|
|
40
|
+
|
|
41
|
+
/** A phase is closed after this many samples whatever they say. */
|
|
42
|
+
export const MAX_RECOVERY_SAMPLES = 600
|
|
43
|
+
|
|
44
|
+
export interface PhaseState {
|
|
45
|
+
phase: Phase
|
|
46
|
+
/** Offsets collected while this phase was open. */
|
|
47
|
+
samples: number[]
|
|
48
|
+
/** Consecutive in-bound samples so far. */
|
|
49
|
+
run: number
|
|
50
|
+
/** Worst absolute offset seen during the transient, in microseconds. */
|
|
51
|
+
worstUs: number
|
|
52
|
+
/** Samples taken before it converged. */
|
|
53
|
+
recoverySamples: number
|
|
54
|
+
/** True once converged; further samples belong to steady state. */
|
|
55
|
+
converged: boolean
|
|
56
|
+
/** True when the safety net closed it instead of convergence. */
|
|
57
|
+
timedOut: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function beginPhase(phase: Phase): PhaseState {
|
|
61
|
+
return {
|
|
62
|
+
phase,
|
|
63
|
+
samples: [],
|
|
64
|
+
run: 0,
|
|
65
|
+
worstUs: 0,
|
|
66
|
+
recoverySamples: 0,
|
|
67
|
+
converged: phase === 'steady',
|
|
68
|
+
timedOut: false,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Feed one offset. Returns the updated phase and where the sample belongs.
|
|
74
|
+
*
|
|
75
|
+
* A sample taken while a transient is open belongs to that transient, never to
|
|
76
|
+
* steady state — merging them would report the transitions as if they were
|
|
77
|
+
* playback.
|
|
78
|
+
*/
|
|
79
|
+
export function observe(
|
|
80
|
+
state: PhaseState,
|
|
81
|
+
offsetUs: number,
|
|
82
|
+
boundUs: number,
|
|
83
|
+
): { state: PhaseState; bucket: Phase } {
|
|
84
|
+
if (state.converged) {
|
|
85
|
+
return { state, bucket: 'steady' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const magnitude = Math.abs(offsetUs)
|
|
89
|
+
const next: PhaseState = {
|
|
90
|
+
...state,
|
|
91
|
+
samples: [...state.samples, offsetUs],
|
|
92
|
+
recoverySamples: state.recoverySamples + 1,
|
|
93
|
+
worstUs: Math.max(state.worstUs, magnitude),
|
|
94
|
+
run: magnitude <= boundUs ? state.run + 1 : 0,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (next.run >= CONVERGED_RUN) {
|
|
98
|
+
next.converged = true
|
|
99
|
+
} else if (next.recoverySamples >= MAX_RECOVERY_SAMPLES) {
|
|
100
|
+
// Closed by the safety net. Marked, so it reads as a failure to converge
|
|
101
|
+
// rather than quietly becoming steady state.
|
|
102
|
+
next.converged = true
|
|
103
|
+
next.timedOut = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { state: next, bucket: state.phase }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** How long recovery took, in frames, or null while still recovering. */
|
|
110
|
+
export function recoveryFrames(state: PhaseState): number | null {
|
|
111
|
+
return state.converged ? state.recoverySamples : null
|
|
112
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { requireNative } from './player'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The AAC encoder's frozen behaviour, re-measurable on demand.
|
|
5
|
+
*
|
|
6
|
+
* The encode loop is 16% of a preview preparation and was 89% of it before it
|
|
7
|
+
* was rewritten. That rewrite was checked against a baseline of natively
|
|
8
|
+
* generated PCM fixtures — inputs that are formulas rather than files, so the
|
|
9
|
+
* two halves of the comparison cannot drift apart — and the check was exact:
|
|
10
|
+
* access-unit count, payload bytes, payload hash, CSD, timestamps, decoded
|
|
11
|
+
* frame count, RMS and transient positions all matched.
|
|
12
|
+
*
|
|
13
|
+
* This is the door to that. Diagnostics-gated in native, so a release build
|
|
14
|
+
* without them refuses.
|
|
15
|
+
*
|
|
16
|
+
* @experimental
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @experimental */
|
|
20
|
+
export interface EncoderGateResult {
|
|
21
|
+
ok: boolean
|
|
22
|
+
fixture?: string
|
|
23
|
+
/** Compressed shape. */
|
|
24
|
+
units?: number
|
|
25
|
+
payloadBytes?: number
|
|
26
|
+
payloadHash?: number
|
|
27
|
+
csd0?: number
|
|
28
|
+
firstUs?: number
|
|
29
|
+
lastUs?: number
|
|
30
|
+
headTimestamps?: number[]
|
|
31
|
+
/** Decoded result. */
|
|
32
|
+
decodedFrames?: number
|
|
33
|
+
decodedRms?: number
|
|
34
|
+
peak?: number
|
|
35
|
+
markerPositionsMs?: number[]
|
|
36
|
+
expectedMarkersMs?: number[]
|
|
37
|
+
/** What it cost. */
|
|
38
|
+
encodeMs?: number
|
|
39
|
+
inputFrames?: number
|
|
40
|
+
fileBytes?: number
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The fixtures the gate can run. @experimental */
|
|
45
|
+
export const ENCODER_GATE_FIXTURES = [
|
|
46
|
+
'short',
|
|
47
|
+
'acceptance',
|
|
48
|
+
'silence',
|
|
49
|
+
'saturated',
|
|
50
|
+
'markers',
|
|
51
|
+
] as const
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Encode one fixture and describe the result completely.
|
|
55
|
+
*
|
|
56
|
+
* Never throws: a gate that is unavailable is an answer.
|
|
57
|
+
*
|
|
58
|
+
* @experimental
|
|
59
|
+
*/
|
|
60
|
+
export function runEncoderGate(fixture: string, maxInputSize = 65536): EncoderGateResult {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(requireNative().encoderGateRun(fixture, maxInputSize)) as EncoderGateResult
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { VideoExportDestination } from '@pictor-sdk/video-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the file goes, and what is refused before anything is encoded.
|
|
5
|
+
*
|
|
6
|
+
* ## Refusal, never fallback
|
|
7
|
+
*
|
|
8
|
+
* A destination that cannot be honoured fails the export naming the reason. The
|
|
9
|
+
* tempting alternative — write to the cache and return that instead — produces
|
|
10
|
+
* an export that reports success while the file is nowhere the caller asked for,
|
|
11
|
+
* and nothing downstream can tell that apart from having worked. A host that
|
|
12
|
+
* wants a temporary can ask for one.
|
|
13
|
+
*
|
|
14
|
+
* ## `file:` only, for now
|
|
15
|
+
*
|
|
16
|
+
* A `content:` uri is not a path. A document provider need not be a filesystem,
|
|
17
|
+
* so there is no directory to stage in and no rename to be atomic about;
|
|
18
|
+
* publishing would have to be a copy into an opaque stream, with no way to leave
|
|
19
|
+
* the previous contents intact if it failed halfway. That is a different design,
|
|
20
|
+
* not a missing branch, so it is refused by name.
|
|
21
|
+
*
|
|
22
|
+
* ## Paths do not appear in errors
|
|
23
|
+
*
|
|
24
|
+
* A message that quotes `/data/user/0/com.host.app/files/private/...` puts an
|
|
25
|
+
* app-private path into a log, a crash report and whatever the host displays.
|
|
26
|
+
* Every reason here names the file and nothing above it.
|
|
27
|
+
*
|
|
28
|
+
* @experimental
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A destination this adapter can honour. @experimental */
|
|
32
|
+
export type ResolvedDestination =
|
|
33
|
+
| { kind: 'temporary' }
|
|
34
|
+
| { kind: 'file'; path: string; overwrite: boolean }
|
|
35
|
+
|
|
36
|
+
/** @experimental */
|
|
37
|
+
export interface DestinationDecision {
|
|
38
|
+
destination: ResolvedDestination | null
|
|
39
|
+
/** Empty when `destination` is set. Never contains a full path. */
|
|
40
|
+
reasons: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The last segment, for a message that has to name something.
|
|
45
|
+
*
|
|
46
|
+
* Directories are the private part. A filename the caller chose is already
|
|
47
|
+
* theirs, and without it a refusal cannot say which destination it refused.
|
|
48
|
+
*/
|
|
49
|
+
export function describeTarget(path: string): string {
|
|
50
|
+
const trimmed = path.replace(/\/+$/, '')
|
|
51
|
+
const name = trimmed.slice(trimmed.lastIndexOf('/') + 1)
|
|
52
|
+
return name.length > 0 ? name : '(unnamed)'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const KNOWN_SCHEMES: Record<string, string> = {
|
|
56
|
+
'content:': 'a content uri is not a filesystem path, so it cannot be published atomically',
|
|
57
|
+
'http:': 'a remote destination cannot be written to directly',
|
|
58
|
+
'https:': 'a remote destination cannot be written to directly',
|
|
59
|
+
'android.resource:': 'app resources are read-only',
|
|
60
|
+
'asset:': 'bundled assets are read-only',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @experimental */
|
|
64
|
+
export function resolveDestination(destination?: VideoExportDestination): DestinationDecision {
|
|
65
|
+
if (destination === undefined) return { destination: { kind: 'temporary' }, reasons: [] }
|
|
66
|
+
if (destination.kind === 'temporary') return { destination: { kind: 'temporary' }, reasons: [] }
|
|
67
|
+
if (destination.kind !== 'file') {
|
|
68
|
+
return {
|
|
69
|
+
destination: null,
|
|
70
|
+
reasons: [`destination kind '${String((destination as { kind: string }).kind)}' is not supported`],
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const uri = destination.uri
|
|
75
|
+
if (typeof uri !== 'string' || uri.trim().length === 0) {
|
|
76
|
+
return { destination: null, reasons: ['the file destination has no uri'] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.exec(uri)?.[0]?.toLowerCase()
|
|
80
|
+
if (scheme !== undefined && scheme !== 'file:') {
|
|
81
|
+
const known = KNOWN_SCHEMES[scheme]
|
|
82
|
+
return {
|
|
83
|
+
destination: null,
|
|
84
|
+
reasons: [
|
|
85
|
+
known !== undefined
|
|
86
|
+
? `destination scheme '${scheme}' is not supported: ${known}`
|
|
87
|
+
: `destination scheme '${scheme}' is not supported; only file: is`,
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (scheme === undefined) {
|
|
92
|
+
// A bare path is ambiguous — relative to what? — and accepting it would
|
|
93
|
+
// silently resolve against a working directory nobody set.
|
|
94
|
+
return { destination: null, reasons: ['the file destination must be a file: uri'] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let path: string
|
|
98
|
+
try {
|
|
99
|
+
// `file:///a/b` and `file:/a/b` both name an absolute path; `file://host/a`
|
|
100
|
+
// names another machine's, which is not something to guess at.
|
|
101
|
+
const withoutScheme = uri.slice('file:'.length)
|
|
102
|
+
const rest = withoutScheme.startsWith('//') ? withoutScheme.slice(2) : withoutScheme
|
|
103
|
+
if (withoutScheme.startsWith('//') && !rest.startsWith('/')) {
|
|
104
|
+
return {
|
|
105
|
+
destination: null,
|
|
106
|
+
reasons: ['the file destination names a remote host, which is not supported'],
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
path = decodeURIComponent(rest)
|
|
110
|
+
} catch {
|
|
111
|
+
return { destination: null, reasons: ['the file destination uri is malformed'] }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!path.startsWith('/')) {
|
|
115
|
+
return { destination: null, reasons: ['the file destination must be an absolute path'] }
|
|
116
|
+
}
|
|
117
|
+
if (path.endsWith('/')) {
|
|
118
|
+
return {
|
|
119
|
+
destination: null,
|
|
120
|
+
reasons: ['the file destination is a directory; it must name the file to write'],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (path.includes('\0')) {
|
|
124
|
+
return { destination: null, reasons: ['the file destination uri is malformed'] }
|
|
125
|
+
}
|
|
126
|
+
/*
|
|
127
|
+
`..` is refused rather than normalised.
|
|
128
|
+
|
|
129
|
+
Resolving it would mean a destination that names one directory and writes to
|
|
130
|
+
another, and the staging file — which must live beside the target for the
|
|
131
|
+
publication to be a same-filesystem rename — would be placed by arithmetic
|
|
132
|
+
the caller did not do.
|
|
133
|
+
*/
|
|
134
|
+
if (path.split('/').some((segment) => segment === '..')) {
|
|
135
|
+
return { destination: null, reasons: ['the file destination must not contain ".."'] }
|
|
136
|
+
}
|
|
137
|
+
const name = describeTarget(path)
|
|
138
|
+
if (name === '.' || name === '(unnamed)') {
|
|
139
|
+
return {
|
|
140
|
+
destination: null,
|
|
141
|
+
reasons: ['the file destination is a directory; it must name the file to write'],
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
destination: { kind: 'file', path, overwrite: destination.overwrite === true },
|
|
147
|
+
reasons: [],
|
|
148
|
+
}
|
|
149
|
+
}
|