@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
package/src/exportJob.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { VideoExportCancelled } from '@pictor-sdk/video-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One export at a time, settled exactly once, cancellable anywhere.
|
|
5
|
+
*
|
|
6
|
+
* The experimental exporter blocked the bridge for a whole encode and threw
|
|
7
|
+
* from `cancel()`. That was honest — a silent no-op behind a cancel button is
|
|
8
|
+
* worse than a refusal — but it is not something a host can ship, so this is the
|
|
9
|
+
* orchestration that makes cancellation real.
|
|
10
|
+
*
|
|
11
|
+
* ## Why cancellation cannot just reject the promise
|
|
12
|
+
*
|
|
13
|
+
* A Transformer nobody is listening to still encodes, still writes and still
|
|
14
|
+
* holds its codecs. Rejecting in JavaScript would hand the host a cancelled
|
|
15
|
+
* state over an export that is still running, still filling the cache and still
|
|
16
|
+
* about to produce a file nobody is waiting for. So `cancel` reaches native
|
|
17
|
+
* first and the promise settles from the job's own checkpoints.
|
|
18
|
+
*
|
|
19
|
+
* ## Checkpoints
|
|
20
|
+
*
|
|
21
|
+
* Cancellation is cooperative. The work is a sequence of stages, each of which
|
|
22
|
+
* calls {@link ExportJob.checkpoint} on the way past, and the first checkpoint
|
|
23
|
+
* after a cancel throws. That is why the stages are named: a cancellation
|
|
24
|
+
* carries where it stopped, which is the difference between a log that says
|
|
25
|
+
* "cancelled" and one that says which resources were live at the time.
|
|
26
|
+
*
|
|
27
|
+
* ## Settling once
|
|
28
|
+
*
|
|
29
|
+
* Every path — success, failure, cancellation — clears the active job, and
|
|
30
|
+
* nothing may be delivered afterwards. A progress callback that arrives after a
|
|
31
|
+
* host has shown a result is a UI that goes backwards from "done".
|
|
32
|
+
*
|
|
33
|
+
* @experimental
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The lifecycle points an export can be stopped at. @experimental */
|
|
37
|
+
export const EXPORT_STAGES = [
|
|
38
|
+
'resolving',
|
|
39
|
+
'overlays',
|
|
40
|
+
'shards',
|
|
41
|
+
'cadence',
|
|
42
|
+
'audio',
|
|
43
|
+
'concatenating',
|
|
44
|
+
'finalising',
|
|
45
|
+
'publishing',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
/** @experimental */
|
|
49
|
+
export type ExportStage = (typeof EXPORT_STAGES)[number]
|
|
50
|
+
|
|
51
|
+
/** @experimental */
|
|
52
|
+
export interface ExportJob {
|
|
53
|
+
readonly id: string
|
|
54
|
+
/**
|
|
55
|
+
* Give cancellation a chance to take effect.
|
|
56
|
+
*
|
|
57
|
+
* Throws {@link VideoExportCancelled} if the job has been cancelled. Called
|
|
58
|
+
* between stages and inside long loops, because a cancel that can only be
|
|
59
|
+
* observed when the work finishes is not a cancel.
|
|
60
|
+
*/
|
|
61
|
+
checkpoint(stage: ExportStage): void
|
|
62
|
+
/** Deliver progress, unless the job has settled. */
|
|
63
|
+
progress(value: number, deliver: (value: number) => void): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @experimental */
|
|
67
|
+
export interface ExportJobsOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Stop the native side of a job.
|
|
70
|
+
*
|
|
71
|
+
* Given the job id so a late cancel cannot stop a *different* export that
|
|
72
|
+
* started in the meantime.
|
|
73
|
+
*/
|
|
74
|
+
cancelNative(jobId: string): void
|
|
75
|
+
/** Injected in tests. */
|
|
76
|
+
now?: () => number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The job domain's ledger.
|
|
81
|
+
*
|
|
82
|
+
* Kept apart from the native run counters on purpose. One export job drives
|
|
83
|
+
* several Transformer runs, so adding a job total to a run total produces a
|
|
84
|
+
* number that reconciles with nothing — and reads like a reconciliation, which
|
|
85
|
+
* is worse than reporting neither.
|
|
86
|
+
*
|
|
87
|
+
* @experimental
|
|
88
|
+
*/
|
|
89
|
+
export interface ExportJobCounts {
|
|
90
|
+
/** Jobs that began. Refused starts are not accepted jobs. */
|
|
91
|
+
accepted: number
|
|
92
|
+
succeeded: number
|
|
93
|
+
cancelled: number
|
|
94
|
+
failed: number
|
|
95
|
+
/** Exactly 0 or 1: one job at a time. */
|
|
96
|
+
active: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @experimental */
|
|
100
|
+
export interface ExportJobs {
|
|
101
|
+
/** The running job's id, or `null`. */
|
|
102
|
+
readonly active: string | null
|
|
103
|
+
/**
|
|
104
|
+
* `accepted === succeeded + cancelled + failed + active`, always.
|
|
105
|
+
*
|
|
106
|
+
* Terminal outcomes are mutually exclusive: a cancelled job is not also a
|
|
107
|
+
* failed one, however it unwound.
|
|
108
|
+
*/
|
|
109
|
+
readonly counts: ExportJobCounts
|
|
110
|
+
/** Run `body` as the one active job. */
|
|
111
|
+
run<T>(body: (job: ExportJob) => Promise<T>): Promise<T>
|
|
112
|
+
/** Ask the running job to stop. Idempotent; a no-op when none is running. */
|
|
113
|
+
cancel(): void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let counter = 0
|
|
117
|
+
|
|
118
|
+
/** @experimental */
|
|
119
|
+
export function createExportJobs(options: ExportJobsOptions): ExportJobs {
|
|
120
|
+
const now = options.now ?? (() => Date.now())
|
|
121
|
+
let active: string | null = null
|
|
122
|
+
let cancelledAt: ExportStage | null = null
|
|
123
|
+
let cancelRequested = false
|
|
124
|
+
let settled = false
|
|
125
|
+
let accepted = 0
|
|
126
|
+
let succeeded = 0
|
|
127
|
+
let cancelled = 0
|
|
128
|
+
let failed = 0
|
|
129
|
+
|
|
130
|
+
const clear = () => {
|
|
131
|
+
active = null
|
|
132
|
+
cancelRequested = false
|
|
133
|
+
cancelledAt = null
|
|
134
|
+
settled = true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
get active() {
|
|
139
|
+
return active
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
get counts() {
|
|
143
|
+
return {
|
|
144
|
+
accepted,
|
|
145
|
+
succeeded,
|
|
146
|
+
cancelled,
|
|
147
|
+
failed,
|
|
148
|
+
active: active === null ? 0 : 1,
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async run<T>(body: (job: ExportJob) => Promise<T>): Promise<T> {
|
|
153
|
+
if (active !== null) {
|
|
154
|
+
/*
|
|
155
|
+
Refused, not queued.
|
|
156
|
+
|
|
157
|
+
The native module runs one export per process. Interleaving two would
|
|
158
|
+
have them share a Transformer, a muxer and a cache directory, and the
|
|
159
|
+
first symptom would be a corrupt file rather than an error.
|
|
160
|
+
*/
|
|
161
|
+
throw new Error(
|
|
162
|
+
`createMedia3VideoExporter: an export is already running (${active}); ` +
|
|
163
|
+
'cancel it before starting another',
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
counter += 1
|
|
168
|
+
accepted += 1
|
|
169
|
+
const id = `export_${now().toString(36)}_${counter.toString(36)}`
|
|
170
|
+
active = id
|
|
171
|
+
cancelRequested = false
|
|
172
|
+
cancelledAt = null
|
|
173
|
+
settled = false
|
|
174
|
+
|
|
175
|
+
const job: ExportJob = {
|
|
176
|
+
id,
|
|
177
|
+
checkpoint(stage) {
|
|
178
|
+
if (cancelRequested) {
|
|
179
|
+
cancelledAt = stage
|
|
180
|
+
throw new VideoExportCancelled(id, stage)
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
progress(value, deliver) {
|
|
184
|
+
if (settled || cancelRequested) return
|
|
185
|
+
deliver(value)
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const result = await body(job)
|
|
191
|
+
/*
|
|
192
|
+
A cancel that arrived while the last stage was finishing still wins.
|
|
193
|
+
|
|
194
|
+
The alternative is resolving with a file the user asked us not to
|
|
195
|
+
make, which is the outcome a cancel button exists to prevent.
|
|
196
|
+
*/
|
|
197
|
+
if (cancelRequested) {
|
|
198
|
+
throw new VideoExportCancelled(id, cancelledAt ?? 'finalising')
|
|
199
|
+
}
|
|
200
|
+
clear()
|
|
201
|
+
succeeded += 1
|
|
202
|
+
return result
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const wasCancelled = cancelRequested
|
|
205
|
+
const stage = cancelledAt ?? 'finalising'
|
|
206
|
+
clear()
|
|
207
|
+
if (wasCancelled) cancelled += 1
|
|
208
|
+
else failed += 1
|
|
209
|
+
if (wasCancelled) {
|
|
210
|
+
/*
|
|
211
|
+
The cancellation is the outcome, whatever the body threw.
|
|
212
|
+
|
|
213
|
+
Tearing down a Transformer mid-encode raises its own errors, and
|
|
214
|
+
reporting one of those would tell a host the export failed when the
|
|
215
|
+
user had simply stopped it.
|
|
216
|
+
*/
|
|
217
|
+
if (error instanceof VideoExportCancelled) throw error
|
|
218
|
+
throw new VideoExportCancelled(id, stage)
|
|
219
|
+
}
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
cancel() {
|
|
225
|
+
// No job, nothing to stop, nothing to say. Repeated presses reach native
|
|
226
|
+
// once, because a second stop for the same job is not a second request.
|
|
227
|
+
if (active === null || cancelRequested) return
|
|
228
|
+
cancelRequested = true
|
|
229
|
+
options.cancelNative(active)
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import type { VideoExportProgress } from '@pictor-sdk/video-core'
|
|
2
|
+
import type { ExportStage } from './exportJob'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How far an export has got, and how to tell slow from stuck.
|
|
6
|
+
*
|
|
7
|
+
* ## Weighting is derived, not decided
|
|
8
|
+
*
|
|
9
|
+
* Equal percentages per stage would be a lie the bar tells: a 0.1× shard writes
|
|
10
|
+
* three hundred frames while its neighbour writes sixty, and splitting them
|
|
11
|
+
* evenly makes the first appear stuck and the second appear instant. The weights
|
|
12
|
+
* come from the work the compiler already planned — output frames per shard,
|
|
13
|
+
* audio samples, the mux — so the bar moves at the rate the encoder does.
|
|
14
|
+
*
|
|
15
|
+
* Output frames, specifically. Cadence changes how many frames a shard *writes*
|
|
16
|
+
* for a given source, and writing is what the progress is measuring the passage
|
|
17
|
+
* of; counting source frames would make slow motion look fast and fast motion
|
|
18
|
+
* look stuck.
|
|
19
|
+
*
|
|
20
|
+
* ## What 1 means
|
|
21
|
+
*
|
|
22
|
+
* One is not "the encoder finished". It is "the destination is complete and
|
|
23
|
+
* readable", which is a later moment: there is still a mux, and after that a
|
|
24
|
+
* publication. Reporting 1 at the end of encoding produces a "done" that cannot
|
|
25
|
+
* yet be opened, which is worse than a bar that sits at 97 for a moment.
|
|
26
|
+
*
|
|
27
|
+
* ## Progress and heartbeat are different signals
|
|
28
|
+
*
|
|
29
|
+
* **Progress** is completed work shown to a person, throttled to a handful of
|
|
30
|
+
* updates a second because nobody can read more than that and every update
|
|
31
|
+
* costs a bridge crossing.
|
|
32
|
+
*
|
|
33
|
+
* **Heartbeat** is evidence that work is still happening. It is not throttled,
|
|
34
|
+
* it does not have to move the visible percentage, and it is counted natively:
|
|
35
|
+
* a single muxed sample batch or a block of copied destination bytes is a
|
|
36
|
+
* heartbeat even when the bar does not visibly change for a second.
|
|
37
|
+
*
|
|
38
|
+
* Conflating them is what makes a watchdog wrong in both directions. Keyed on
|
|
39
|
+
* visible progress, a long mux or a large destination copy looks stalled
|
|
40
|
+
* because its percentage barely moves; keyed on elapsed time per stage, a 0.1×
|
|
41
|
+
* shard that legitimately runs for minutes looks stalled too. What actually
|
|
42
|
+
* marks a wedge is **silence** — no heartbeat from any source at all.
|
|
43
|
+
*
|
|
44
|
+
* @experimental
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** What a shard will write. @experimental */
|
|
48
|
+
export interface ShardPlan {
|
|
49
|
+
/** Output frames, after cadence. */
|
|
50
|
+
frames: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @experimental */
|
|
54
|
+
export interface ProgressPlanInput {
|
|
55
|
+
shards: readonly ShardPlan[]
|
|
56
|
+
/** Mixed audio samples the timeline will produce. Zero when silent. */
|
|
57
|
+
audioSamples: number
|
|
58
|
+
durationUs: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fractions of the whole export, summing to 1. @experimental */
|
|
62
|
+
export interface ProgressPlan {
|
|
63
|
+
shardWeights: number[]
|
|
64
|
+
audioWeight: number
|
|
65
|
+
/** Concatenation and mux: real work, and the last thing before publication. */
|
|
66
|
+
muxWeight: number
|
|
67
|
+
/**
|
|
68
|
+
* Publication: writing the destination and making it readable.
|
|
69
|
+
*
|
|
70
|
+
* Held back deliberately so the encoded-and-muxed state cannot reach 1. A bar
|
|
71
|
+
* that fills before the file exists produces a "done" that cannot be opened,
|
|
72
|
+
* which is a worse lie than sitting at 97 for a moment.
|
|
73
|
+
*/
|
|
74
|
+
publishWeight: number
|
|
75
|
+
durationUs: number
|
|
76
|
+
/** The totals the fractions are measured against. */
|
|
77
|
+
shardFrames: number[]
|
|
78
|
+
audioSamples: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How long without a heartbeat counts as wedged.
|
|
83
|
+
*
|
|
84
|
+
* Derived from the longest silence real work produces, not from how long the
|
|
85
|
+
* platform takes to notice a wedge. The first version had that backwards — it
|
|
86
|
+
* justified twelve seconds by sitting *past* Media3's observed 10.5 s stall
|
|
87
|
+
* detection, which makes a watchdog decorative: it can only ever be the second
|
|
88
|
+
* thing to notice.
|
|
89
|
+
*
|
|
90
|
+
* The maximum legitimate heartbeat gap, measured across **three passes of seven
|
|
91
|
+
* cases** on a Pixel 7 — short and long 1×, 0.1× and 10× cadence, mixed-speed
|
|
92
|
+
* multi-clip, muted, and project audio — is **2046 ms**, in the 0.1× case. Seven
|
|
93
|
+
* seconds is 3.4× that, and 3.5 s ahead of the platform's own 10.5 s stall
|
|
94
|
+
* detection. Both margins are the point: too close to the first and it fires on
|
|
95
|
+
* healthy work, too close to the second and it is not worth having.
|
|
96
|
+
*
|
|
97
|
+
* Three passes rather than one because a single pass is not a maximum. The same
|
|
98
|
+
* 0.1× case measured 1668, 1426 and 2046 ms in three different sessions, and a
|
|
99
|
+
* threshold frozen from the lowest of those would have been derived from a run
|
|
100
|
+
* that happened to go well.
|
|
101
|
+
*
|
|
102
|
+
* ## Two corrections this number carries
|
|
103
|
+
*
|
|
104
|
+
* The first series measured 1668 ms with gate provenance hashing running — every
|
|
105
|
+
* sample of both intermediates and the result read and digested, beating as it
|
|
106
|
+
* went. A threshold calibrated against work the user never pays for is
|
|
107
|
+
* calibrated against the wrong export, so the hashing moved behind a conformance
|
|
108
|
+
* flag. The second series missed the *per-shard* hashing, which was still
|
|
109
|
+
* running. With all of it gated the worst source moved from `muxBatch` to
|
|
110
|
+
* `audioBlock`: the mux beats on its own sample batches, and once the hashing
|
|
111
|
+
* that was reading them stopped, the audio mix became the quietest stage again.
|
|
112
|
+
*
|
|
113
|
+
* ## The headroom here is small, and that is the finding
|
|
114
|
+
*
|
|
115
|
+
* 3.4× the worst gap and 3.5 s before the platform leaves roughly one doubling
|
|
116
|
+
* of the worst legitimate silence before the two constraints meet. On a slower
|
|
117
|
+
* encoder there may be no gap between them at all, which is why
|
|
118
|
+
* `Media3ExporterOptions.stallGraceMs` exists.
|
|
119
|
+
*
|
|
120
|
+
* Any of this was only measurable after the silence was instrumented rather
|
|
121
|
+
* than tolerated. Before, the maximum gap was 5.4 s and no useful threshold
|
|
122
|
+
* existed between it and the platform's timeout; the gap turned out to be the
|
|
123
|
+
* Sonic time-stretch and AAC encode inside the audio mix, which at 0.1× turn one
|
|
124
|
+
* second of source into ten with nothing beating.
|
|
125
|
+
*
|
|
126
|
+
* @experimental
|
|
127
|
+
*/
|
|
128
|
+
export const STALL_GRACE_MS = 7_000
|
|
129
|
+
|
|
130
|
+
/** The measured maximum, kept beside the threshold it justifies. @experimental */
|
|
131
|
+
export const MAX_OBSERVED_HEARTBEAT_GAP_MS = 2_046
|
|
132
|
+
|
|
133
|
+
/** A unit of work, in the same currency as the weights. */
|
|
134
|
+
const AUDIO_UNIT = 1 / 512
|
|
135
|
+
const MUX_UNITS = 24
|
|
136
|
+
const PUBLISH_UNITS = 8
|
|
137
|
+
|
|
138
|
+
/** @experimental */
|
|
139
|
+
export function planProgressWeights(input: ProgressPlanInput): ProgressPlan {
|
|
140
|
+
const shardUnits = input.shards.map((s) => Math.max(0, s.frames))
|
|
141
|
+
const audioUnits = input.audioSamples > 0 ? input.audioSamples * AUDIO_UNIT : 0
|
|
142
|
+
const total =
|
|
143
|
+
shardUnits.reduce((a, b) => a + b, 0) + audioUnits + MUX_UNITS + PUBLISH_UNITS
|
|
144
|
+
// The fixed mux and publish units keep this above zero, so an empty timeline
|
|
145
|
+
// weighs its publication rather than dividing by nothing.
|
|
146
|
+
return {
|
|
147
|
+
shardWeights: shardUnits.map((u) => u / total),
|
|
148
|
+
audioWeight: audioUnits / total,
|
|
149
|
+
muxWeight: MUX_UNITS / total,
|
|
150
|
+
publishWeight: PUBLISH_UNITS / total,
|
|
151
|
+
durationUs: Math.max(0, input.durationUs),
|
|
152
|
+
shardFrames: shardUnits,
|
|
153
|
+
audioSamples: Math.max(0, input.audioSamples),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @experimental */
|
|
158
|
+
export interface ProgressReporterOptions {
|
|
159
|
+
plan: ProgressPlan
|
|
160
|
+
durationUs: number
|
|
161
|
+
onProgress?: (progress: VideoExportProgress) => void
|
|
162
|
+
/** Bridge crossings are bounded to roughly this spacing. */
|
|
163
|
+
throttleMs?: number
|
|
164
|
+
now?: () => number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @experimental */
|
|
168
|
+
export interface ProgressReporter {
|
|
169
|
+
/** Emit 0, once, when work begins. */
|
|
170
|
+
begin(): void
|
|
171
|
+
/** Report a shard's completed output frames. */
|
|
172
|
+
shard(index: number, framesDone: number): void
|
|
173
|
+
/** Report mixed audio samples. */
|
|
174
|
+
audio(samplesDone: number): void
|
|
175
|
+
/** Concatenation and mux finished. Not 1. */
|
|
176
|
+
muxed(): void
|
|
177
|
+
/** The destination is complete and readable. Emits 1, once. */
|
|
178
|
+
complete(): void
|
|
179
|
+
/**
|
|
180
|
+
* Apply several updates and emit at most once.
|
|
181
|
+
*
|
|
182
|
+
* A poll reads every shard, the audio and the mux together, and without this
|
|
183
|
+
* the first of those spends the throttle slot and the rest are dropped until
|
|
184
|
+
* the next tick — so the last shard's frames and the whole audio mix could
|
|
185
|
+
* arrive a tick late or, at the end of an export, never. One read of native
|
|
186
|
+
* state is one update to the bar.
|
|
187
|
+
*/
|
|
188
|
+
batch(body: () => void): void
|
|
189
|
+
/** Deliver nothing further. */
|
|
190
|
+
stop(): void
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @experimental */
|
|
194
|
+
export function createProgressReporter(options: ProgressReporterOptions): ProgressReporter {
|
|
195
|
+
const now = options.now ?? (() => Date.now())
|
|
196
|
+
const throttleMs = options.throttleMs ?? 120
|
|
197
|
+
const plan = options.plan
|
|
198
|
+
const shardDone = plan.shardWeights.map(() => 0)
|
|
199
|
+
let audioDone = 0
|
|
200
|
+
let muxDone = 0
|
|
201
|
+
let publishDone = 0
|
|
202
|
+
let highest = 0
|
|
203
|
+
let began = false
|
|
204
|
+
let finished = false
|
|
205
|
+
let stopped = false
|
|
206
|
+
let lastEmitMs = Number.NEGATIVE_INFINITY
|
|
207
|
+
let batching = 0
|
|
208
|
+
let batchForce = false
|
|
209
|
+
|
|
210
|
+
const fraction = () => {
|
|
211
|
+
let sum = 0
|
|
212
|
+
for (let i = 0; i < shardDone.length; i += 1) {
|
|
213
|
+
sum += plan.shardWeights[i]! * shardDone[i]!
|
|
214
|
+
}
|
|
215
|
+
return sum + plan.audioWeight * audioDone + plan.muxWeight * muxDone + plan.publishWeight * publishDone
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const emit = (value: number, force: boolean) => {
|
|
219
|
+
/*
|
|
220
|
+
Nothing crosses before the export is declared started.
|
|
221
|
+
|
|
222
|
+
Everything that can refuse an export runs before `begin`, and a bar that
|
|
223
|
+
appears and then vanishes because a photo clip was rejected claims work
|
|
224
|
+
that never happened.
|
|
225
|
+
*/
|
|
226
|
+
if (stopped || !began) return
|
|
227
|
+
if (batching > 0) {
|
|
228
|
+
// Held until the batch closes. `force` survives, because a completion
|
|
229
|
+
// inside a batch is still a completion.
|
|
230
|
+
batchForce = batchForce || force
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
/*
|
|
234
|
+
Monotonic by construction.
|
|
235
|
+
|
|
236
|
+
A native counter that restarts — a retried shard, a stage re-entered —
|
|
237
|
+
must not drag the bar backwards, so what is reported is the high-water
|
|
238
|
+
mark rather than the latest computation.
|
|
239
|
+
*/
|
|
240
|
+
const next = Math.max(highest, Math.min(1, value))
|
|
241
|
+
const at = now()
|
|
242
|
+
if (!force && at - lastEmitMs < throttleMs) return
|
|
243
|
+
/*
|
|
244
|
+
A repeat is not an update.
|
|
245
|
+
|
|
246
|
+
Polling reports the same fraction every tick while a shard encodes, and
|
|
247
|
+
delivering it is a bridge crossing that tells a host nothing — measured on
|
|
248
|
+
device, a one-second export spent three of its ten callbacks re-sending 0.
|
|
249
|
+
Liveness is the heartbeat's job, and it does not go through here.
|
|
250
|
+
*/
|
|
251
|
+
if (!force && began && next === highest) return
|
|
252
|
+
highest = next
|
|
253
|
+
lastEmitMs = at
|
|
254
|
+
/*
|
|
255
|
+
A host's callback is not allowed to end the export.
|
|
256
|
+
|
|
257
|
+
It runs on the caller's side and can throw for reasons that have nothing
|
|
258
|
+
to do with encoding — a render error, a disposed component. Aborting a
|
|
259
|
+
working encode because a progress indicator failed would be the wrong
|
|
260
|
+
trade in every case.
|
|
261
|
+
*/
|
|
262
|
+
try {
|
|
263
|
+
options.onProgress?.({
|
|
264
|
+
progress: highest,
|
|
265
|
+
time: Math.min(plan.durationUs, Math.round(plan.durationUs * highest)),
|
|
266
|
+
})
|
|
267
|
+
} catch {
|
|
268
|
+
// Deliberately swallowed; see above.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
begin() {
|
|
274
|
+
if (began || stopped) return
|
|
275
|
+
began = true
|
|
276
|
+
emit(0, true)
|
|
277
|
+
},
|
|
278
|
+
shard(index, framesDone) {
|
|
279
|
+
if (finished || stopped) return
|
|
280
|
+
if (index < 0 || index >= shardDone.length) return
|
|
281
|
+
const total = Math.max(1, plan.shardFrames[index] ?? 1)
|
|
282
|
+
shardDone[index] = Math.max(shardDone[index]!, Math.min(1, framesDone / total))
|
|
283
|
+
emit(fraction(), false)
|
|
284
|
+
},
|
|
285
|
+
audio(samplesDone) {
|
|
286
|
+
if (finished || stopped) return
|
|
287
|
+
const total = Math.max(1, plan.audioSamples)
|
|
288
|
+
audioDone = Math.max(audioDone, Math.min(1, samplesDone / total))
|
|
289
|
+
emit(fraction(), false)
|
|
290
|
+
},
|
|
291
|
+
muxed() {
|
|
292
|
+
if (finished || stopped) return
|
|
293
|
+
muxDone = 1
|
|
294
|
+
emit(fraction(), true)
|
|
295
|
+
},
|
|
296
|
+
complete() {
|
|
297
|
+
if (finished || stopped) return
|
|
298
|
+
finished = true
|
|
299
|
+
publishDone = 1
|
|
300
|
+
emit(1, true)
|
|
301
|
+
},
|
|
302
|
+
batch(body) {
|
|
303
|
+
batching += 1
|
|
304
|
+
try {
|
|
305
|
+
body()
|
|
306
|
+
} finally {
|
|
307
|
+
batching -= 1
|
|
308
|
+
}
|
|
309
|
+
const force = batchForce
|
|
310
|
+
batchForce = false
|
|
311
|
+
emit(finished ? 1 : fraction(), force)
|
|
312
|
+
},
|
|
313
|
+
stop() {
|
|
314
|
+
stopped = true
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** What a wedged export looked like when it was noticed. @experimental */
|
|
320
|
+
export interface StallVerdict {
|
|
321
|
+
stage: ExportStage | string
|
|
322
|
+
units: number
|
|
323
|
+
idleMs: number
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** @experimental */
|
|
327
|
+
export interface StallWatchdogOptions {
|
|
328
|
+
thresholdMs?: number
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Where a heartbeat came from, so a gap can be attributed. @experimental */
|
|
332
|
+
export const HEARTBEAT_SOURCES = [
|
|
333
|
+
'videoFrame',
|
|
334
|
+
'cadence',
|
|
335
|
+
'audioBlock',
|
|
336
|
+
'muxBatch',
|
|
337
|
+
'publishBytes',
|
|
338
|
+
] as const
|
|
339
|
+
|
|
340
|
+
/** @experimental */
|
|
341
|
+
export type HeartbeatSource = (typeof HEARTBEAT_SOURCES)[number]
|
|
342
|
+
|
|
343
|
+
/** @experimental */
|
|
344
|
+
export interface StallWatchdog {
|
|
345
|
+
/**
|
|
346
|
+
* Record a heartbeat. Any beat counts, whether or not it moved the bar.
|
|
347
|
+
*
|
|
348
|
+
* `units` is carried for the report only — a repeated count is still a
|
|
349
|
+
* heartbeat, because the pipeline spoke.
|
|
350
|
+
*/
|
|
351
|
+
advanced(stage: ExportStage | string, units: number, atMs: number): void
|
|
352
|
+
/** The verdict if nothing has reported for the threshold, else `null`. */
|
|
353
|
+
stalledFor(atMs: number): StallVerdict | null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Slow is not stuck.
|
|
358
|
+
*
|
|
359
|
+
* Keyed on *any* report rather than on elapsed time per stage: a 0.1× export
|
|
360
|
+
* spends minutes inside one shard and is perfectly healthy while doing it, and
|
|
361
|
+
* a stage timeout would either fire on that or be too long to catch anything.
|
|
362
|
+
* What distinguishes a wedge is silence.
|
|
363
|
+
*
|
|
364
|
+
* Fires once. A wedged export is one event, and the cleanup it triggers is the
|
|
365
|
+
* same path cancellation already proved.
|
|
366
|
+
*
|
|
367
|
+
* @experimental
|
|
368
|
+
*/
|
|
369
|
+
export function createStallWatchdog(options: StallWatchdogOptions = {}): StallWatchdog {
|
|
370
|
+
const thresholdMs = options.thresholdMs ?? STALL_GRACE_MS
|
|
371
|
+
let lastStage: ExportStage | string | null = null
|
|
372
|
+
let lastUnits = 0
|
|
373
|
+
let lastAtMs = 0
|
|
374
|
+
let fired = false
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
advanced(stage, units, atMs) {
|
|
378
|
+
lastStage = stage
|
|
379
|
+
lastUnits = units
|
|
380
|
+
lastAtMs = atMs
|
|
381
|
+
},
|
|
382
|
+
stalledFor(atMs) {
|
|
383
|
+
// Nothing has started, so nothing is stalled.
|
|
384
|
+
if (fired || lastStage === null) return null
|
|
385
|
+
const idleMs = atMs - lastAtMs
|
|
386
|
+
if (idleMs < thresholdMs) return null
|
|
387
|
+
fired = true
|
|
388
|
+
return { stage: lastStage, units: lastUnits, idleMs }
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
}
|