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