@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,655 @@
1
+ import { createExportJobs } from './exportJob'
2
+ import { resolveMixSources } from './mixSources'
3
+ import { describeTarget, resolveDestination } from './exportDestination'
4
+ import { planProgressWeights } from './exportProgress'
5
+ import { createProgressPump } from './exportProgressPump'
6
+ import {
7
+ VideoExportCancelled,
8
+ VideoExportFailed,
9
+ VideoExportStalled,
10
+ compileAudioMixProgram,
11
+ compileVideoSceneProgram,
12
+ isPlayableWindow,
13
+ layout,
14
+ planClipShards,
15
+ projectDuration,
16
+ validateAudioMixProgram,
17
+ validateProject,
18
+ VIDEO_SCENE_PROGRAM_VERSION,
19
+ type ExportSourceContext,
20
+ type MediaSource,
21
+ type ResampleRequest,
22
+ type VideoExportProgress,
23
+ type VideoExportSpec,
24
+ type VideoExporter,
25
+ type VideoProject,
26
+ } from '@pictor-sdk/video-core'
27
+ import NativePictorVideoModule from './NativePictorVideo'
28
+
29
+ /** The turbo module, or a failure that says so rather than a null dereference. */
30
+ function nativeModule(): NonNullable<typeof NativePictorVideoModule> {
31
+ if (!NativePictorVideoModule) {
32
+ throw new VideoExportRejected(['the Pictor video native module is not installed'])
33
+ }
34
+ return NativePictorVideoModule
35
+ }
36
+
37
+ /**
38
+ * The Media3 exporter.
39
+ *
40
+ * Honours `VideoExporter` in full: asynchronous, cancellable while it runs,
41
+ * monotonic progress, temporary and file destinations, one export at a time,
42
+ * typed refusal, cancellation, stall and failure, and deterministic cleanup on
43
+ * every one of those paths.
44
+ *
45
+ * Nothing constructs it for you. It is not on `VideoRuntime`, not behind the
46
+ * editor's Next button, and not reachable from `@pictor-sdk/video-ui` — a host
47
+ * builds it explicitly, which is what keeps the surface reviewable. Wiring it
48
+ * to a button is Slice 5's job, not this file's.
49
+ *
50
+ * ## The gate boundary
51
+ *
52
+ * Nothing here reaches a `exportGate*` method. Provenance hashing, fault
53
+ * injection, artificial holds and test beats live behind
54
+ * `exportGateEnableDiagnostics`, which this never calls, and which leaves them
55
+ * inert even when a payload asks for them.
56
+ *
57
+ * ## The whole project is interpreted here, in TypeScript
58
+ *
59
+ * `planClipShards` and `compileVideoSceneProgram` run once, sources are resolved
60
+ * once, overlays are rasterised once, and what crosses to native is a list of
61
+ * shards, a compiled program and a resource manifest — all JSON and file paths.
62
+ * Native never sees a `VideoProject`, a `VideoClip`, a trim, a speed or a
63
+ * transform field. It looks up segments by timestamp and draws what they say.
64
+ *
65
+ * ## It refuses rather than approximating
66
+ *
67
+ * Every clip kind, spec field and overlay state this slice does not implement is
68
+ * rejected by name before anything is encoded. A photo clip silently skipped, or
69
+ * a bitrate silently ignored, produces a file that is plausible and wrong — and
70
+ * a plausible wrong file is worse than no file, because nothing downstream can
71
+ * tell it apart from a correct one.
72
+ *
73
+ * @experimental
74
+ */
75
+
76
+ /**
77
+ * An overlay raster, described structurally.
78
+ *
79
+ * Deliberately not imported from `@pictor-sdk/video-skia`. The adapter has no
80
+ * opinion about how an overlay is rendered — that is the whole point of the
81
+ * boundary — and importing the renderer would drag Skia into a package whose
82
+ * job is to talk to Media3. What it needs is the shape of a descriptor, which
83
+ * is JSON.
84
+ *
85
+ * @experimental
86
+ */
87
+ export interface OverlayResourceDescriptor {
88
+ overlayId: string
89
+ resourceId: string
90
+ size: { width: number; height: number }
91
+ rasterBounds: { x: number; y: number; width: number; height: number }
92
+ rasterScale: number
93
+ rasterWidth: number
94
+ rasterHeight: number
95
+ format: string
96
+ premultiplied: boolean
97
+ colorSpace: string
98
+ file: string
99
+ byteLength: number
100
+ checksum: number
101
+ }
102
+
103
+ /** What preparing a program's overlays produced. @experimental */
104
+ export interface OverlayPreparationResult {
105
+ resources: readonly OverlayResourceDescriptor[]
106
+ /** Still resolving. An export must not proceed and omit them. */
107
+ pending: readonly string[]
108
+ /** Cannot be exported at all. */
109
+ invalid: readonly { overlayId: string; kind: string; reason: string }[]
110
+ }
111
+
112
+ /** Everything the host must supply. @experimental */
113
+ export interface Media3ExporterOptions {
114
+ /**
115
+ * Turn an opaque `MediaSource` into something Android can open.
116
+ *
117
+ * The exporter never inspects a descriptor: `MediaSource` is `JsonValue` and
118
+ * only the host knows whether it is an asset id, a content URI or a row in a
119
+ * database. Returning `null` fails the export naming the clip or the span.
120
+ *
121
+ * `context` says which part of the project is being resolved. It used to be
122
+ * the clip, which left the audio path with nothing honest to pass: every
123
+ * span, project music included, was resolved with the *first video clip* as
124
+ * context. A resolver that keys on the clip returned that clip's media for
125
+ * the whole timeline's audio — a plausible file with the wrong sound in it.
126
+ */
127
+ resolveSource(source: MediaSource, context: ExportSourceContext): string | null
128
+ /**
129
+ * Rasterise every overlay the compiled program names.
130
+ *
131
+ * The host wires its registry, its prepared resources and its raster sink
132
+ * behind this — typically `prepareProgramOverlayExports` from
133
+ * `@pictor-sdk/video-skia`. Injecting the whole step rather than its three inputs
134
+ * is what keeps this package free of a renderer dependency, and it is the same
135
+ * reason `resolveSource` is injected rather than a media library being.
136
+ *
137
+ * Omitted means "no overlays are exportable": a project that has any then
138
+ * fails naming each one, rather than exporting without them.
139
+ */
140
+ prepareOverlays?(
141
+ program: { segments: readonly { commands: readonly unknown[] }[] },
142
+ projectSize: { width: number; height: number },
143
+ rasterScale: number,
144
+ ): OverlayPreparationResult
145
+ /**
146
+ * Called after an export finishes, successfully or not.
147
+ *
148
+ * The sidecars belong to the host's raster sink, so the host removes them.
149
+ * Passing the list rather than the sink keeps this from having to know how
150
+ * they were stored.
151
+ */
152
+ releaseResources?(files: readonly string[]): void
153
+ /**
154
+ * How long without any heartbeat counts as wedged.
155
+ *
156
+ * Defaults to {@link STALL_GRACE_MS}, which is **Pixel 7 evidence**: the worst
157
+ * legitimate silence measured across seven production-path cases on that
158
+ * device, with margin. It is not a claim about Android. A slower encoder, a
159
+ * thermally throttled phone or a codec that batches differently can produce a
160
+ * longer honest gap, and the number to change is this one — raising it costs
161
+ * only how late a genuine wedge is noticed, while leaving it too low kills
162
+ * healthy exports.
163
+ */
164
+ stallGraceMs?: number
165
+ /**
166
+ * How often native counters are read, and progress delivered.
167
+ *
168
+ * Defaults to 125 ms — 8 Hz, inside the 5–10 Hz a person can actually read.
169
+ * Native counting is unthrottled regardless; this is only the bridge rate.
170
+ */
171
+ progressPollMs?: number
172
+ }
173
+
174
+ /** Why an export will not start. @experimental */
175
+ export class VideoExportRejected extends Error {
176
+ readonly reasons: readonly string[]
177
+ constructor(reasons: readonly string[]) {
178
+ super(`export rejected: ${reasons.join('; ')}`)
179
+ this.name = 'VideoExportRejected'
180
+ this.reasons = reasons
181
+ }
182
+ }
183
+
184
+ /** The frame rate an export runs at when the spec does not say. */
185
+ const DEFAULT_FPS = 30
186
+
187
+
188
+ /**
189
+ * What this slice cannot encode, stated rather than skipped.
190
+ *
191
+ * Each of these has a milestone. Until then the export fails naming the clip or
192
+ * the field, because the alternative is a file that looks finished.
193
+ */
194
+ function describeUnsupported(project: VideoProject, spec: VideoExportSpec): string[] {
195
+ const reasons: string[] = []
196
+
197
+ for (const clip of project.clips) {
198
+ if (clip.kind === 'photo') {
199
+ reasons.push(`clip '${clip.id}' is a photo, which this exporter does not encode yet`)
200
+ continue
201
+ }
202
+ }
203
+
204
+ if (spec.format !== undefined && spec.format !== 'mp4') {
205
+ reasons.push(`format '${spec.format}' is not supported; this exporter writes mp4`)
206
+ }
207
+ if (spec.scale !== undefined && spec.scale !== 1) {
208
+ // Honoured or rejected, never quietly ignored.
209
+ reasons.push(`scale ${spec.scale} is not supported yet; only 1 is`)
210
+ }
211
+ if (spec.fps !== undefined && spec.fps !== DEFAULT_FPS) {
212
+ reasons.push(`fps ${spec.fps} is not supported yet; only ${DEFAULT_FPS} is`)
213
+ }
214
+ if (spec.bitrate !== undefined) {
215
+ reasons.push('bitrate is not supported yet; the encoder default is used')
216
+ }
217
+ return reasons
218
+ }
219
+
220
+ /**
221
+ * Build an exporter.
222
+ *
223
+ * @experimental
224
+ */
225
+ export function createMedia3VideoExporter(options: Media3ExporterOptions): VideoExporter {
226
+ /*
227
+ One job at a time, cancellable at every stage.
228
+
229
+ The registry owns identity, exclusivity and settling exactly once; the body
230
+ below only has to call `job.checkpoint` where stopping is safe. See
231
+ `exportJob.ts` for why cancellation cannot simply reject the promise.
232
+ */
233
+ const jobs = createExportJobs({
234
+ cancelNative: (jobId) => {
235
+ nativeModule().exportCancelJob(jobId)
236
+ },
237
+ })
238
+
239
+ return {
240
+ async export(
241
+ project: VideoProject,
242
+ spec: VideoExportSpec,
243
+ onProgress?: (progress: VideoExportProgress) => void,
244
+ ) {
245
+ return jobs.run(async (job) => {
246
+ // First thing, so a cancel pressed before any work is observed before
247
+ // the compile, the overlay pass and the source resolution rather than
248
+ // after them. Measured: without it a pre-native cancel took 4.7 s to
249
+ // settle, because the first checkpoint was past all of that.
250
+ job.checkpoint('resolving')
251
+ const issues = validateProject(project)
252
+ if (issues.length > 0) {
253
+ throw new VideoExportRejected(issues.map((issue) => issue.message))
254
+ }
255
+ const unsupported = describeUnsupported(project, spec)
256
+ if (unsupported.length > 0) throw new VideoExportRejected(unsupported)
257
+
258
+ /*
259
+ Refused, never redirected.
260
+
261
+ Falling back to a temporary would produce an export that reports success
262
+ while the file is nowhere the caller asked for — and nothing downstream
263
+ can tell that apart from having worked.
264
+ */
265
+ const decision = resolveDestination(spec.destination)
266
+ if (decision.destination === null) throw new VideoExportRejected(decision.reasons)
267
+ const destination = decision.destination
268
+
269
+ const playable = project.clips.filter((clip) => isPlayableWindow(clip))
270
+ if (playable.length === 0) throw new VideoExportRejected(['the project has no playable clips'])
271
+ if (!(projectDuration(project) > 0)) {
272
+ throw new VideoExportRejected(['the project has no duration'])
273
+ }
274
+
275
+ // Resolved without inspecting the descriptor: only the host knows what a
276
+ // `MediaSource` is, and this must not learn.
277
+ const uris: string[] = []
278
+ const unresolved: string[] = []
279
+ for (const clip of playable) {
280
+ const uri = options.resolveSource(clip.source, { kind: 'clip', clipId: clip.id })
281
+ if (typeof uri !== 'string' || uri.length === 0) {
282
+ unresolved.push(`clip '${clip.id}' could not be resolved to a readable source`)
283
+ continue
284
+ }
285
+ uris.push(uri)
286
+ }
287
+ if (unresolved.length > 0) throw new VideoExportRejected(unresolved)
288
+
289
+ const fps = DEFAULT_FPS
290
+ const placements = layout(project)
291
+ const requests: ResampleRequest[] = playable.map((clip) => ({
292
+ sourceStartUs: clip.sourceIn,
293
+ sourceEndUs: clip.sourceOut,
294
+ speed: clip.speed,
295
+ sourceFps: clip.metadata.fps ?? fps,
296
+ targetFps: fps,
297
+ }))
298
+ const plan = planClipShards(requests, fps)
299
+ const program = compileVideoSceneProgram(project)
300
+ if (program.segments.length === 0) {
301
+ throw new VideoExportRejected(['the compiled program has no segments'])
302
+ }
303
+ void placements
304
+
305
+ /*
306
+ Overlays are rasterised and checked **before** any native work.
307
+
308
+ A resource discovered missing once encoding has started leaves a partial
309
+ file, and the frames already written are silently missing something the
310
+ timeline said was on screen.
311
+ */
312
+ const named = new Set<string>()
313
+ for (const segment of program.segments) {
314
+ for (const command of segment.commands) {
315
+ if (command.op === 'overlay') named.add(command.overlayId)
316
+ }
317
+ }
318
+ const overlayPlan: OverlayPreparationResult = options.prepareOverlays
319
+ ? options.prepareOverlays(program, project.size, 1)
320
+ : {
321
+ resources: [],
322
+ pending: [],
323
+ invalid: [...named].map((overlayId) => ({
324
+ overlayId,
325
+ kind: 'unknown',
326
+ reason: 'no prepareOverlays was supplied, so this overlay has no portable form',
327
+ })),
328
+ }
329
+ // Before the plan is judged: a cancel raised from inside the host's
330
+ // preparation must be reported as `overlays`, not as whatever the
331
+ // validation below happens to say first.
332
+ job.checkpoint('overlays')
333
+
334
+ /*
335
+ Anything already staged is released on every failure path below.
336
+
337
+ A rejection after rasterising leaves sidecars nothing else will come back
338
+ for, and the run that failed is exactly the one nobody cleans up after.
339
+ */
340
+ const stagedNow = overlayPlan.resources.map((resource) => resource.file)
341
+ if (overlayPlan.pending.length > 0) {
342
+ options.releaseResources?.(stagedNow)
343
+ throw new VideoExportRejected(
344
+ overlayPlan.pending.map(
345
+ (id: string) => `overlay '${id}' is still preparing; export would omit it`,
346
+ ),
347
+ )
348
+ }
349
+ if (overlayPlan.invalid.length > 0) {
350
+ options.releaseResources?.(stagedNow)
351
+ throw new VideoExportRejected(
352
+ overlayPlan.invalid.map(
353
+ (bad: { overlayId: string; kind: string; reason: string }) =>
354
+ `overlay '${bad.overlayId}' (${bad.kind}): ${bad.reason}`,
355
+ ),
356
+ )
357
+ }
358
+ const resources = overlayPlan.resources
359
+
360
+ const shards = plan.map((shard, index) => {
361
+ const clip = playable[index]!
362
+ const cadence = cadenceFor(shard.plan.frames)
363
+ return {
364
+ uri: uris[index]!,
365
+ startMs: Math.round(clip.sourceIn / 1000),
366
+ endMs: Math.round(clip.sourceOut / 1000),
367
+ speed: clip.speed,
368
+ timelineOffsetUs: shard.timelineOffsetUs,
369
+ /*
370
+ The shard's own audio is dropped and replaced with generated silence.
371
+
372
+ Not because anything wants that silence — the final mux takes only
373
+ the video track from this pipeline — but because Media3 drives its
374
+ speed path through audio, and a video-only item at any rate other
375
+ than 1 stalls with "no output sample written". So the track exists to
376
+ satisfy the re-timing and is discarded.
377
+
378
+ What matters is that the *source's* audio never enters this pipeline:
379
+ a per-shard audio track has to be re-encoded to concatenate, which is
380
+ what put a second generation on everything the user hears.
381
+ */
382
+ forceAudioTrack: true,
383
+ removeAudio: true,
384
+ emitsPerInput: cadence.emitsPerInput,
385
+ outputPtsUs: cadence.outputPtsUs,
386
+ spanUs: Math.round((shard.plan.frameCount * 1_000_000) / fps),
387
+ }
388
+ })
389
+
390
+ /*
391
+ The whole timeline's audio, compiled once and resolved once.
392
+
393
+ `compileAudioMixProgram` settles placements, trims, gains and fades; the
394
+ backend mixes and encodes exactly what it says. Sources are resolved by
395
+ span id through the same host resolver the video pipeline uses, and a
396
+ silent span needs none.
397
+ */
398
+ const mixProgram = compileAudioMixProgram(project)
399
+ const mixProblems = validateAudioMixProgram(mixProgram)
400
+ if (mixProblems.length > 0) throw new VideoExportRejected(mixProblems)
401
+ /*
402
+ Resolved as what each span actually is, not as whichever clip came
403
+ first — and by the same function preview preparation calls, so the two
404
+ cannot disagree about which file a span means. A disagreement there
405
+ would be audible only in the finished video.
406
+ */
407
+ const resolved = resolveMixSources(project, mixProgram, options.resolveSource)
408
+ const mixUris = resolved.uris
409
+ if (resolved.unresolved.length > 0) throw new VideoExportRejected(resolved.unresolved)
410
+ job.checkpoint('resolving')
411
+
412
+ job.checkpoint('overlays')
413
+ const staged = resources.map((resource) => resource.file)
414
+
415
+ /*
416
+ Progress starts here, after everything that can refuse.
417
+
418
+ A bar that appears and then vanishes because a photo clip was rejected
419
+ is worse than one that never appeared: the export never began, and
420
+ showing 0% claims otherwise. Everything above this line either throws or
421
+ establishes the plan the weights are computed from.
422
+ */
423
+ const durationUs = projectDuration(project)
424
+ const progressPlan = planProgressWeights({
425
+ shards: plan.map((shard) => ({ frames: shard.plan.frameCount })),
426
+ audioSamples: mixProgram.spans.some((span) => !span.silent)
427
+ ? Math.max(0, mixProgram.durationUs)
428
+ : 0,
429
+ durationUs,
430
+ })
431
+ // A box rather than a `let`: the assignment happens in a callback, and
432
+ // TypeScript narrows a closed-over `let` to `never` at the read.
433
+ const stalled: { at: { stage: string; idleMs: number } | null } = { at: null }
434
+ const pump = createProgressPump({
435
+ plan: progressPlan,
436
+ durationUs,
437
+ read: () => JSON.parse(nativeModule().exportProgressSnapshot()) as never,
438
+ // Delivered through the job, so nothing arrives after settlement: a
439
+ // host that has already shown a result must not be told 90%.
440
+ onProgress: onProgress
441
+ ? (value) => { job.progress(value.progress, () => onProgress(value)) }
442
+ : undefined,
443
+ onStall: (verdict) => {
444
+ stalled.at = { stage: String(verdict.stage), idleMs: verdict.idleMs }
445
+ /*
446
+ Torn down through the path cancellation already proved.
447
+
448
+ A wedged export holds a Transformer, a muxer and its codecs, and the
449
+ only mechanism in this process that reliably lets go of all three is
450
+ the native cancel. What differs is what the caller is told: nobody
451
+ asked to stop, so this settles as `VideoExportStalled`.
452
+ */
453
+ nativeModule().exportCancelJob(job.id)
454
+ },
455
+ thresholdMs: options.stallGraceMs,
456
+ pollMs: options.progressPollMs,
457
+ })
458
+ pump.begin()
459
+ const ticker = setInterval(() => { pump.tick() }, pump.pollMs)
460
+
461
+ let outcome: { ok: boolean; uri: string; error: string }
462
+ try {
463
+ outcome = JSON.parse(
464
+ await nativeModule().exportRunProjectAsync(
465
+ job.id,
466
+ JSON.stringify(shards),
467
+ JSON.stringify({
468
+ version: VIDEO_SCENE_PROGRAM_VERSION,
469
+ projectSize: { width: project.size.width, height: project.size.height },
470
+ durationUs: program.durationUs,
471
+ segments: program.segments,
472
+ }),
473
+ JSON.stringify(resources),
474
+ JSON.stringify(mixProgram),
475
+ JSON.stringify(mixUris),
476
+ /* timeoutSeconds= */ 600,
477
+ destination.kind === 'file'
478
+ ? JSON.stringify({
479
+ kind: 'file',
480
+ path: destination.path,
481
+ overwrite: destination.overwrite,
482
+ })
483
+ : '',
484
+ ),
485
+ ) as { ok: boolean; uri: string; error: string }
486
+ } finally {
487
+ clearInterval(ticker)
488
+ // Every path, including the one that threw: the sidecars are this
489
+ // export's and nothing else will come back for them.
490
+ options.releaseResources?.(staged)
491
+ }
492
+
493
+ /*
494
+ A stall outranks whatever native said on the way out.
495
+
496
+ Tearing down a wedged Transformer reports itself as a cancellation,
497
+ because a cancellation is how it was torn down. Reporting that would
498
+ tell a host the user pressed stop when nothing of the sort happened.
499
+ */
500
+ const wedged = stalled.at
501
+ if (wedged) {
502
+ pump.stop()
503
+ throw new VideoExportStalled(job.id, wedged.stage, wedged.idleMs)
504
+ }
505
+
506
+ /*
507
+ A native cancellation is a cancellation, with the stage native recorded.
508
+
509
+ Without this the job registry falls back to its own default and every
510
+ cancellation reports the same stage, which is worse than no stage at
511
+ all: it looks like information.
512
+ */
513
+ const cancelledDuring = /^cancelled during (\w+)/.exec(outcome.error ?? '')
514
+ if (!outcome.ok && cancelledDuring) {
515
+ pump.stop()
516
+ throw new VideoExportCancelled(job.id, cancelledDuring[1]!)
517
+ }
518
+
519
+ if (!outcome.ok) {
520
+ /*
521
+ No uri either way.
522
+
523
+ A partial file is worse than none — it plays, it is the wrong length,
524
+ and a caller that took the uri would have no way to know. Native
525
+ deletes what it created on every one of its own failure paths; nothing
526
+ is swept here, because a sweep would also take the temporary a
527
+ *previous* export handed the caller.
528
+ */
529
+ pump.stop()
530
+ const error = outcome.error || 'the export failed'
531
+ if (isRefusal(error)) throw new VideoExportRejected([error])
532
+ throw new VideoExportFailed(job.id, 'encoding', error)
533
+ }
534
+ job.checkpoint('publishing')
535
+
536
+ /*
537
+ One is the destination being readable, not the encoder stopping.
538
+
539
+ The container is closed after the last sample is written, and a file
540
+ that is closed is not necessarily one that parses. Probing it costs a
541
+ `MediaExtractor` open and turns "the encode finished" into "there is a
542
+ file you can play", which is the only claim a bar reaching 1 should be
543
+ making.
544
+ */
545
+ const probe = JSON.parse(nativeModule().exportProbeOutput(outcome.uri)) as {
546
+ readable: boolean
547
+ error?: string
548
+ tracks?: number
549
+ }
550
+ if (!probe.readable) {
551
+ pump.stop()
552
+ // Written and unopenable: the work happened and the platform produced
553
+ // something unusable, which is a failure rather than a refusal.
554
+ throw new VideoExportFailed(
555
+ job.id,
556
+ 'publishing',
557
+ `${describeTarget(outcome.uri)} could not be read back: ${probe.error ?? 'no video track'}`,
558
+ )
559
+ }
560
+ pump.complete()
561
+ pump.stop()
562
+
563
+ /*
564
+ Ownership, stated in the type.
565
+
566
+ A temporary is the adapter's until the caller says otherwise, so it
567
+ carries the one call that disposes of it. A file destination is the
568
+ caller's — they chose the path — so that branch has no `release` at all,
569
+ rather than one that quietly does nothing. An optional cleanup method on
570
+ a single shape would put "delete the user's video" one typo away from
571
+ every success path.
572
+ */
573
+ if (destination.kind === 'file') return { kind: 'file', uri: outcome.uri }
574
+ const temporaryUri = outcome.uri
575
+ return {
576
+ kind: 'temporary',
577
+ uri: temporaryUri,
578
+ release: async () => {
579
+ // Idempotent, and scoped to this file: native refuses any path it did
580
+ // not create, so a second call — or one after the cache was cleared —
581
+ // is not an error.
582
+ await Promise.resolve()
583
+ nativeModule().exportReleaseTemporaryOutput(temporaryUri)
584
+ },
585
+ }
586
+ })
587
+ },
588
+
589
+ /**
590
+ * Ask the running export to stop.
591
+ *
592
+ * Idempotent, and a no-op when nothing is running. Reaches the native job
593
+ * before the promise settles: a Transformer nobody is listening to still
594
+ * encodes, still writes and still holds its codecs, so rejecting in
595
+ * JavaScript alone would show a cancelled state over a running export.
596
+ */
597
+ cancel(): void {
598
+ jobs.cancel()
599
+ },
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Refusal or failure — the distinction a caller acts on.
605
+ *
606
+ * A refusal means the project, the destination or a resource was not something
607
+ * this adapter could encode, decided before or instead of doing work: change
608
+ * the input. A failure means the work started and the platform gave up: retry,
609
+ * check storage, or report a bug. Reporting one as the other sends a caller to
610
+ * the wrong place, which is worse than an unhelpful message.
611
+ *
612
+ * Matched on the prefixes native produces rather than on a code, because the
613
+ * native side reports strings — and the list is exhaustive over the reasons
614
+ * `runProject` can refuse, so anything unrecognised is a failure. That default
615
+ * is the safe one: a new encoder error misreported as "unsupported" tells a user
616
+ * to edit a project that was fine.
617
+ */
618
+ const REFUSAL_PATTERNS = [
619
+ /^program rejected/,
620
+ /^overlay resources? /,
621
+ /^overlay resource /,
622
+ /^no shards/,
623
+ /already exists; pass overwrite/,
624
+ /^the directory for /,
625
+ /^the destination /,
626
+ /cannot be written without overwrite/,
627
+ /^the staged file could not be read back/,
628
+ ]
629
+
630
+ function isRefusal(error: string): boolean {
631
+ return REFUSAL_PATTERNS.some((pattern) => pattern.test(error))
632
+ }
633
+
634
+
635
+ /**
636
+ * The cadence transport: copies owed per arriving input, and their timestamps.
637
+ *
638
+ * Straight from `resampleClip`'s plan. Native walks these arrays and computes no
639
+ * grid arithmetic of its own.
640
+ */
641
+ function cadenceFor(
642
+ frames: readonly { outputPtsUs: number; sourceFrameIndex: number }[],
643
+ ): { emitsPerInput: number[]; outputPtsUs: number[] } {
644
+ const first = frames[0]!.sourceFrameIndex
645
+ const last = frames[frames.length - 1]!.sourceFrameIndex
646
+ const emitsPerInput: number[] = []
647
+ for (let i = 0; i <= last - first; i += 1) emitsPerInput.push(0)
648
+ const outputPtsUs: number[] = []
649
+ for (const frame of frames) {
650
+ const slot = frame.sourceFrameIndex - first
651
+ emitsPerInput[slot] = (emitsPerInput[slot] ?? 0) + 1
652
+ outputPtsUs.push(frame.outputPtsUs)
653
+ }
654
+ return { emitsPerInput, outputPtsUs }
655
+ }