@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
package/dist/index.mjs ADDED
@@ -0,0 +1,3625 @@
1
+ // src/memoryGuard.ts
2
+ var DEFAULT_CEILING_BYTES = 250 * 1024 * 1024;
3
+ var DEFAULT_WINDOW_SAMPLES = 40;
4
+ var DEFAULT_RISE_BYTES = 8 * 1024 * 1024;
5
+ function createMemoryGuard(ceilingBytes = DEFAULT_CEILING_BYTES, windowSamples = DEFAULT_WINDOW_SAMPLES, riseBytes = DEFAULT_RISE_BYTES) {
6
+ return {
7
+ ceilingBytes,
8
+ windowSamples: Math.max(3, Math.floor(windowSamples)),
9
+ riseBytes,
10
+ samples: [],
11
+ tripped: null
12
+ };
13
+ }
14
+ function observeMemory(guard, bytes) {
15
+ if (guard.tripped) return guard;
16
+ if (!Number.isFinite(bytes) || bytes <= 0) return guard;
17
+ if (bytes >= guard.ceilingBytes) {
18
+ return { ...guard, samples: [...guard.samples, bytes].slice(-guard.windowSamples), tripped: "ceiling" };
19
+ }
20
+ const samples = [...guard.samples, bytes].slice(-guard.windowSamples);
21
+ if (samples.length < guard.windowSamples) return { ...guard, samples };
22
+ let climbing = true;
23
+ for (let i = 1; i < samples.length; i++) {
24
+ if (samples[i] <= samples[i - 1]) {
25
+ climbing = false;
26
+ break;
27
+ }
28
+ }
29
+ const climb = samples[samples.length - 1] - samples[0];
30
+ if (climbing && climb >= guard.riseBytes) {
31
+ return { ...guard, samples, tripped: "sustained-rise" };
32
+ }
33
+ return { ...guard, samples };
34
+ }
35
+ function describeTrip(guard) {
36
+ if (!guard.tripped) return null;
37
+ const mb = (b) => (b / (1024 * 1024)).toFixed(0);
38
+ const latest = guard.samples[guard.samples.length - 1] ?? 0;
39
+ return guard.tripped === "ceiling" ? `native heap reached ${mb(latest)} MB (ceiling ${mb(guard.ceilingBytes)} MB) \u2014 session released` : `native heap rose ${mb(latest - (guard.samples[0] ?? 0))} MB across ${guard.samples.length} samples without plateauing \u2014 session released`;
40
+ }
41
+
42
+ // src/syncTrace.ts
43
+ var MAX_SAMPLES = 2e4;
44
+ function createSyncTrace(fps) {
45
+ const rate = Number.isFinite(fps) && fps > 0 ? fps : 30;
46
+ return {
47
+ frameUs: Math.round(1e6 / rate),
48
+ quantisation: [],
49
+ decoderLate: [],
50
+ schedulerLate: [],
51
+ correction: [],
52
+ presentedOffset: [],
53
+ tickInterval: [],
54
+ ticks: 0,
55
+ decoderLateTicks: 0,
56
+ schedulerLateTicks: 0
57
+ };
58
+ }
59
+ function expectedPtsAt(audioUs, frameUs) {
60
+ if (audioUs <= 0 || frameUs <= 0) return 0;
61
+ return Math.floor(audioUs / frameUs) * frameUs;
62
+ }
63
+ function record(trace, sample) {
64
+ if (trace.ticks >= MAX_SAMPLES) return { ...trace, ticks: trace.ticks + 1 };
65
+ const expected = expectedPtsAt(sample.audioUs, trace.frameUs);
66
+ const next = {
67
+ ...trace,
68
+ ticks: trace.ticks + 1,
69
+ quantisation: [...trace.quantisation, sample.audioUs - expected],
70
+ correction: [...trace.correction, Math.abs(sample.correctionUs)],
71
+ tickInterval: [...trace.tickInterval, sample.tickIntervalUs],
72
+ decoderLate: trace.decoderLate,
73
+ schedulerLate: trace.schedulerLate,
74
+ presentedOffset: trace.presentedOffset,
75
+ decoderLateTicks: trace.decoderLateTicks,
76
+ schedulerLateTicks: trace.schedulerLateTicks
77
+ };
78
+ const eligible = sample.newestEligiblePtsUs;
79
+ if (eligible === null || eligible < expected) {
80
+ const shortfall = eligible === null ? sample.audioUs : expected - eligible;
81
+ next.decoderLate = [...trace.decoderLate, shortfall];
82
+ next.decoderLateTicks = trace.decoderLateTicks + 1;
83
+ }
84
+ if (eligible !== null && (sample.presentedPtsUs === null || sample.presentedPtsUs < eligible)) {
85
+ const behind = eligible - (sample.presentedPtsUs ?? 0);
86
+ next.schedulerLate = [...trace.schedulerLate, behind];
87
+ next.schedulerLateTicks = trace.schedulerLateTicks + 1;
88
+ }
89
+ if (sample.presentedPtsUs !== null) {
90
+ next.presentedOffset = [...trace.presentedOffset, sample.presentedPtsUs - sample.audioUs];
91
+ }
92
+ return next;
93
+ }
94
+ function distribution(values) {
95
+ if (values.length === 0) return { n: 0, p50: 0, p95: 0, p99: 0, max: 0 };
96
+ const abs = values.map(Math.abs).sort((a, b) => a - b);
97
+ const at = (q) => abs[Math.min(abs.length - 1, Math.floor(q * abs.length))];
98
+ return { n: abs.length, p50: at(0.5), p95: at(0.95), p99: at(0.99), max: abs[abs.length - 1] };
99
+ }
100
+ function syncReport(trace) {
101
+ const decoderLate = distribution(trace.decoderLate);
102
+ const schedulerLate = distribution(trace.schedulerLate);
103
+ const quantisation = distribution(trace.quantisation);
104
+ const presentedOffset = distribution(trace.presentedOffset);
105
+ const decoderShare = trace.ticks === 0 ? 0 : trace.decoderLateTicks / trace.ticks;
106
+ const schedulerShare = trace.ticks === 0 ? 0 : trace.schedulerLateTicks / trace.ticks;
107
+ let dominant = "none";
108
+ if (presentedOffset.p95 > trace.frameUs) {
109
+ if (decoderShare >= schedulerShare && decoderShare > 0.01) dominant = "decoder";
110
+ else if (schedulerShare > 0.01) dominant = "scheduler";
111
+ else dominant = "quantisation";
112
+ }
113
+ return {
114
+ quantisation,
115
+ decoderLate,
116
+ schedulerLate,
117
+ correction: distribution(trace.correction),
118
+ presentedOffset,
119
+ tickInterval: distribution(trace.tickInterval),
120
+ ticks: trace.ticks,
121
+ decoderLateTicks: trace.decoderLateTicks,
122
+ schedulerLateTicks: trace.schedulerLateTicks,
123
+ frameUs: trace.frameUs,
124
+ dominant
125
+ };
126
+ }
127
+
128
+ // src/NativePictorVideo.ts
129
+ import { TurboModuleRegistry } from "react-native";
130
+ var NativePictorVideo_default = TurboModuleRegistry.get("PictorVideo");
131
+
132
+ // src/player.ts
133
+ var AndroidVideoPlayer = {
134
+ /**
135
+ * Publish the presentation surface host object on the JS runtime.
136
+ *
137
+ * It is captured into a worklet closure from there; the UI runtime has its
138
+ * own global and does not see this one.
139
+ */
140
+ installVideoSurface() {
141
+ return requireNative().installVideoSurface();
142
+ }
143
+ };
144
+ function requireNative() {
145
+ if (!NativePictorVideo_default) {
146
+ throw new Error(
147
+ "@pictor-sdk/video-android: the native module is not installed. It is Android-only and requires minSdkVersion 29."
148
+ );
149
+ }
150
+ return NativePictorVideo_default;
151
+ }
152
+
153
+ // src/media3.ts
154
+ var EMPTY = {
155
+ positionUs: 0,
156
+ durationUs: 0,
157
+ playing: false,
158
+ buffering: false,
159
+ ready: false,
160
+ ended: false,
161
+ rate: 1,
162
+ width: 0,
163
+ height: 0,
164
+ released: true,
165
+ error: "",
166
+ generation: 0,
167
+ queueDepth: 0,
168
+ overflowed: 0,
169
+ unpaired: 0,
170
+ stale: 0,
171
+ queueHighWater: 0,
172
+ threads: 0,
173
+ nativeHeapBytes: 0,
174
+ anchorPositionUs: 0,
175
+ coalesced: 0,
176
+ anchorPublications: 0,
177
+ anchorChanges: 0,
178
+ anchorMaxStepUs: 0,
179
+ anchorMeanStepUs: 0,
180
+ anchorMaxStaleNs: 0,
181
+ windowIndex: 0,
182
+ windowCount: 0,
183
+ windowPositionUs: 0,
184
+ transitions: 0,
185
+ missedLatches: 0,
186
+ updatesAttempted: 0,
187
+ updatesSucceeded: 0,
188
+ updatesSkipped: 0,
189
+ updatesFailed: 0,
190
+ rateConstrained: 0,
191
+ effectiveRate: 1,
192
+ requestedRate: 1,
193
+ staleWindowSpeeds: 0,
194
+ windowSpeedCount: 0,
195
+ framesOffered: 0,
196
+ framesAvailable: 0,
197
+ pendingDepth: 0,
198
+ framesRejected: 0,
199
+ pendingGeneration: 0,
200
+ controllerId: 0,
201
+ pendingId: 0,
202
+ orphaned: 0,
203
+ videoFormat: "",
204
+ decoderName: "",
205
+ inputFormats: 0,
206
+ inputWidth: 0,
207
+ inputHeight: 0,
208
+ inputRotation: 0,
209
+ decoderReuse: -1,
210
+ outputSizes: 0,
211
+ outputWidth: 0,
212
+ outputHeight: 0,
213
+ surfaceGeneration: 0,
214
+ surfaceAttachments: 0,
215
+ attachedSurfaceGeneration: 0,
216
+ liveSurfaceGeneration: 0,
217
+ seeksRequested: 0,
218
+ seeksAccepted: 0,
219
+ pendingSeek: false,
220
+ playbackState: 0,
221
+ lastPairedPtsUs: -1,
222
+ errorCount: 0,
223
+ errorCode: 0,
224
+ errorMessage: "",
225
+ errorRecoverable: false
226
+ };
227
+ var Media3 = {
228
+ /**
229
+ * Open a local clip and render it into the presentation surface.
230
+ *
231
+ * The surface must already exist: it is created on the UI runtime, where
232
+ * Skia's GL context lives, and preparing without one is an error rather than
233
+ * a silently black picture.
234
+ */
235
+ prepare(uri) {
236
+ return requireNative().media3Prepare(uri);
237
+ },
238
+ /** Diagnostics: a player with no video output, for heap attribution. */
239
+ prepareNoSurface(uri) {
240
+ return requireNative().media3PrepareNoSurface(uri);
241
+ },
242
+ /** Diagnostics: reuse the existing player for another source. */
243
+ rebind(uri) {
244
+ return requireNative().media3Rebind(uri);
245
+ },
246
+ /** Diagnostics: end the source, keep the player. */
247
+ stopKeepingPlayer() {
248
+ requireNative().media3StopKeepingPlayer();
249
+ },
250
+ /**
251
+ * Replace the playlist from a project's playable windows.
252
+ *
253
+ * Takes the `PlaylistResult` straight from `@pictor-sdk/video-core` so the
254
+ * project → window mapping has exactly one definition, tested there as a
255
+ * round trip.
256
+ * The sources must already be URI strings; this adapter does not interpret a
257
+ * host's media descriptors, and a non-URI source is a host normalisation bug
258
+ * rather than something to guess at.
259
+ *
260
+ * The OES texture and the `SkImage` survive this. Changing the playlist is
261
+ * not a reason to rebuild what the renderer holds.
262
+ */
263
+ setPlaylist(playlist, start = { windowIndex: 0, positionUs: 0 }, options = {}) {
264
+ if (playlist.unsupported.length > 0 && options.allowDivergence !== true) {
265
+ throw new Error(
266
+ `@pictor-sdk/video-android: refusing to build a playlist that would diverge from the timeline. ${playlist.unsupported.length} clip(s) cannot play:
267
+ ` + playlist.unsupported.map((u) => ` \xB7 ${u.message}`).join("\n") + "\nPass { allowDivergence: true } to play the remainder anyway."
268
+ );
269
+ }
270
+ const uris = [];
271
+ const bounds = [];
272
+ for (const item of playlist.items) {
273
+ uris.push(uriOf(item.source));
274
+ bounds.push(item.startUs, item.endUs, Math.round(item.speed * 1e3), item.muted ? 1 : 0);
275
+ }
276
+ return requireNative().media3SetPlaylist(uris, bounds, start.windowIndex, start.positionUs);
277
+ },
278
+ /** Seek in the player's coordinates. Pair with `projectTimeToWindow`. */
279
+ seekToWindow(windowIndex, positionUs) {
280
+ requireNative().media3SeekToWindow(windowIndex, positionUs);
281
+ },
282
+ play() {
283
+ requireNative().media3Play();
284
+ },
285
+ pause() {
286
+ requireNative().media3Pause();
287
+ },
288
+ /** Microseconds, like every other time in Pictor. */
289
+ seek(positionUs) {
290
+ requireNative().media3Seek(positionUs);
291
+ },
292
+ setRate(rate) {
293
+ requireNative().media3SetRate(rate);
294
+ },
295
+ /**
296
+ * Try the current source again after a failure.
297
+ *
298
+ * This is Media3's own recovery path: it re-resolves the source and resumes
299
+ * from the current position. Worth calling when `errorRecoverable` is true —
300
+ * a network source that went away and came back — and pointless otherwise.
301
+ */
302
+ retry() {
303
+ requireNative().media3Retry();
304
+ },
305
+ release() {
306
+ requireNative().media3Release();
307
+ },
308
+ /** A snapshot for throttled reads. Never call this per frame. */
309
+ state() {
310
+ const raw = requireNative().media3State();
311
+ return raw ? { ...EMPTY, ...raw } : EMPTY;
312
+ }
313
+ };
314
+ function uriOf(source) {
315
+ if (typeof source === "string") return source;
316
+ if (source && typeof source === "object" && "uri" in source) {
317
+ const uri = source.uri;
318
+ if (typeof uri === "string") return uri;
319
+ }
320
+ throw new Error(
321
+ `@pictor-sdk/video-android: a clip's source is not a URI. Normalise host media to { kind: 'uri', uri } before opening a project. Received: ${JSON.stringify(source)}`
322
+ );
323
+ }
324
+
325
+ // src/playlistSession.ts
326
+ var NO_SURFACE = 0;
327
+ function idlePlaylistSession() {
328
+ return { appliedSignature: null, appliedGeneration: NO_SURFACE, pending: false };
329
+ }
330
+ function shouldPrepare(session, intent) {
331
+ if (intent.generation === NO_SURFACE) return false;
332
+ if (session.pending) return true;
333
+ if (session.appliedSignature !== intent.signature) return true;
334
+ return session.appliedGeneration !== intent.generation;
335
+ }
336
+ function afterPrepare(session, intent, ok) {
337
+ if (!ok) return { ...session, pending: true };
338
+ return {
339
+ appliedSignature: intent.signature,
340
+ appliedGeneration: intent.generation,
341
+ pending: false
342
+ };
343
+ }
344
+ function retireSurface(session) {
345
+ return { ...session, appliedGeneration: NO_SURFACE, pending: true };
346
+ }
347
+ function isCurrentGeneration(generation, live) {
348
+ return generation !== NO_SURFACE && generation === live;
349
+ }
350
+
351
+ // src/surfaceOwner.ts
352
+ function createSurfaceOwner(initial) {
353
+ let effects = initial;
354
+ let readiness = { generation: NO_SURFACE, error: "" };
355
+ let busy = false;
356
+ let retired = 0;
357
+ let activeToken = null;
358
+ let activeRetired = false;
359
+ let currentToken = null;
360
+ let queuedToken = null;
361
+ const listeners = /* @__PURE__ */ new Set();
362
+ const emit = () => {
363
+ for (const listener of listeners) listener();
364
+ };
365
+ const publish = (next) => {
366
+ if (next.generation === readiness.generation && next.error === readiness.error) return;
367
+ readiness = next;
368
+ emit();
369
+ };
370
+ const start = (token) => {
371
+ busy = true;
372
+ activeToken = token;
373
+ activeRetired = false;
374
+ effects.create();
375
+ };
376
+ const owner = {
377
+ get readiness() {
378
+ return readiness;
379
+ },
380
+ get busy() {
381
+ return busy;
382
+ },
383
+ get retired() {
384
+ return retired;
385
+ },
386
+ request(token) {
387
+ currentToken = token;
388
+ if (readiness.generation !== NO_SURFACE) return false;
389
+ if (busy) {
390
+ queuedToken = token;
391
+ return false;
392
+ }
393
+ start(token);
394
+ return true;
395
+ },
396
+ retireOwner(token) {
397
+ if (activeToken === token) activeRetired = true;
398
+ if (currentToken === token) currentToken = null;
399
+ if (queuedToken === token) queuedToken = null;
400
+ },
401
+ adopt(generation, token) {
402
+ currentToken = token;
403
+ if (generation === NO_SURFACE) return;
404
+ publish({ generation, error: "" });
405
+ },
406
+ complete(result) {
407
+ const wasRetired = activeRetired;
408
+ const hasOwner = currentToken !== null;
409
+ busy = false;
410
+ activeToken = null;
411
+ activeRetired = false;
412
+ if (result.ok && result.generation > NO_SURFACE) {
413
+ const superseded = wasRetired || !hasOwner;
414
+ const stale = result.generation < readiness.generation;
415
+ if (superseded || stale) {
416
+ retired += 1;
417
+ effects.retire(result.generation);
418
+ } else {
419
+ publish({ generation: result.generation, error: "" });
420
+ }
421
+ } else {
422
+ publish({
423
+ generation: readiness.generation,
424
+ error: result.error || "surface create failed"
425
+ });
426
+ }
427
+ const next = queuedToken;
428
+ queuedToken = null;
429
+ if (next !== null && readiness.generation === NO_SURFACE && next === currentToken) {
430
+ start(next);
431
+ }
432
+ },
433
+ useEffects(next) {
434
+ effects = next;
435
+ },
436
+ subscribe(listener) {
437
+ listeners.add(listener);
438
+ return () => {
439
+ listeners.delete(listener);
440
+ };
441
+ }
442
+ };
443
+ return owner;
444
+ }
445
+ var singleton = null;
446
+ function surfaceOwner(effects) {
447
+ if (!singleton) singleton = createSurfaceOwner(effects);
448
+ else singleton.useEffects(effects);
449
+ return singleton;
450
+ }
451
+ function resetSurfaceOwnerForTests() {
452
+ singleton = null;
453
+ }
454
+ var nextToken = 0;
455
+ function nextOwnerToken() {
456
+ nextToken += 1;
457
+ return nextToken;
458
+ }
459
+
460
+ // src/useVideoSurface.ts
461
+ import { useCallback, useEffect, useRef, useState } from "react";
462
+ import { AppState } from "react-native";
463
+ import { runOnJS, runOnUI, useSharedValue } from "react-native-reanimated";
464
+ var CREATE_RETRY_MS = 250;
465
+ function videoSurfaceSlot() {
466
+ const published = globalThis.__pictorVideoSurface;
467
+ if (published) return published;
468
+ if (!AndroidVideoPlayer.installVideoSurface()) return void 0;
469
+ return globalThis.__pictorVideoSurface;
470
+ }
471
+ function useVideoSurface(options) {
472
+ const { width, height, enabled = true } = options;
473
+ const [readiness, setReadiness] = useState({ generation: NO_SURFACE, error: "" });
474
+ const handleRef = useRef(void 0);
475
+ if (!handleRef.current) handleRef.current = videoSurfaceSlot();
476
+ const textureId = useSharedValue(0);
477
+ const tokenRef = useRef(0);
478
+ if (tokenRef.current === 0) tokenRef.current = nextOwnerToken();
479
+ const token = tokenRef.current;
480
+ const [attempt, setAttempt] = useState(0);
481
+ const ownerRef = useRef(void 0);
482
+ const complete = useCallback((result) => {
483
+ ownerRef.current?.complete(result);
484
+ }, []);
485
+ const owner = surfaceOwner({
486
+ create() {
487
+ const published = handleRef.current;
488
+ if (!published) {
489
+ complete({ ok: false, generation: 0, textureId: 0, error: "__pictorVideoSurface missing" });
490
+ return;
491
+ }
492
+ runOnUI(() => {
493
+ "worklet";
494
+ const created = published.create(width, height);
495
+ textureId.value = created.textureId;
496
+ if (created.ok && !(created.generation > 0)) {
497
+ runOnJS(complete)({
498
+ ok: false,
499
+ generation: 0,
500
+ textureId: created.textureId,
501
+ error: "adapter too old: create() reports no generation"
502
+ });
503
+ return;
504
+ }
505
+ runOnJS(complete)({
506
+ ok: created.ok && created.textureId > 0,
507
+ generation: created.generation,
508
+ textureId: created.textureId,
509
+ // The native reason, not a summary of it. "No EGL context current on
510
+ // this thread" and "no JNIEnv" send a reader to different places.
511
+ error: created.ok ? "" : created.error || "surface create failed"
512
+ });
513
+ })();
514
+ },
515
+ retire(generation) {
516
+ const published = handleRef.current;
517
+ if (!published || typeof published.releaseGeneration !== "function") return;
518
+ runOnUI(() => {
519
+ "worklet";
520
+ published.releaseGeneration(generation);
521
+ })();
522
+ }
523
+ });
524
+ ownerRef.current = owner;
525
+ useEffect(() => {
526
+ const unsubscribe = owner.subscribe(() => setReadiness(owner.readiness));
527
+ setReadiness(owner.readiness);
528
+ return unsubscribe;
529
+ }, [owner]);
530
+ const ensure = useCallback(() => {
531
+ if (!enabled) return;
532
+ const published = videoSurfaceSlot();
533
+ if (!published) {
534
+ setReadiness((prev) => ({ ...prev, error: "__pictorVideoSurface missing" }));
535
+ return;
536
+ }
537
+ handleRef.current = published;
538
+ if (typeof published.alive === "function" && typeof published.generation === "function" && published.alive() && published.generation() !== NO_SURFACE) {
539
+ owner.adopt(published.generation(), token);
540
+ return;
541
+ }
542
+ owner.request(token);
543
+ }, [enabled, owner, token]);
544
+ useEffect(() => {
545
+ ensure();
546
+ }, [ensure, attempt]);
547
+ useEffect(() => {
548
+ if (!enabled) return;
549
+ if (readiness.generation !== NO_SURFACE) return;
550
+ const id = setInterval(() => setAttempt((n) => n + 1), CREATE_RETRY_MS);
551
+ return () => clearInterval(id);
552
+ }, [enabled, readiness.generation]);
553
+ useEffect(() => {
554
+ if (!enabled) return;
555
+ const onChange = (status) => {
556
+ if (status === "active") {
557
+ ensure();
558
+ return;
559
+ }
560
+ owner.retireOwner(token);
561
+ };
562
+ const subscription = AppState.addEventListener("change", onChange);
563
+ return () => subscription.remove();
564
+ }, [enabled, ensure, owner, token]);
565
+ useEffect(() => () => owner.retireOwner(token), [owner, token]);
566
+ return {
567
+ // Always the slot, never undefined once installed. Gating this on readiness
568
+ // is what put a null into the consumer's closure and kept it there.
569
+ surface: handleRef.current,
570
+ generation: readiness.generation,
571
+ error: readiness.error
572
+ };
573
+ }
574
+
575
+ // src/useCompositionPlayback.ts
576
+ import { useCallback as useCallback2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
577
+ import {
578
+ playIntent,
579
+ playlistOf as playlistOf2,
580
+ projectDuration
581
+ } from "@pictor-sdk/video-core";
582
+
583
+ // src/compositionSpike.ts
584
+ import { playlistOf } from "@pictor-sdk/video-core";
585
+ function compositionSpikeSpec(project, mix, resolve) {
586
+ const playlist = playlistOf(project);
587
+ const items = [];
588
+ const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`);
589
+ for (const item of playlist.items) {
590
+ const uri = resolve(item.source);
591
+ if (uri === null) {
592
+ unsupported.push(`${item.clipId}: unresolved`);
593
+ continue;
594
+ }
595
+ items.push({
596
+ uri,
597
+ startUs: Number(item.startUs),
598
+ endUs: Number(item.endUs),
599
+ speed: item.speed
600
+ });
601
+ }
602
+ return {
603
+ spec: {
604
+ items,
605
+ mixUri: mix.uri,
606
+ mixDurationUs: mix.durationUs,
607
+ width: project.size.width,
608
+ height: project.size.height
609
+ },
610
+ unsupported
611
+ };
612
+ }
613
+ function call(fn, label) {
614
+ try {
615
+ return JSON.parse(fn());
616
+ } catch (error) {
617
+ return { ok: false, error: `${label}: ${String(error)}` };
618
+ }
619
+ }
620
+ function spikeClaim() {
621
+ const result = call(() => requireNative().spikeClaim(), "spikeClaim");
622
+ return typeof result.requestId === "number" ? result.requestId : 0;
623
+ }
624
+ function spikeStart(spec) {
625
+ return call(() => requireNative().spikeStart(JSON.stringify(spec)), "spikeStart");
626
+ }
627
+ function spikePlay() {
628
+ return call(() => requireNative().spikePlay(), "spikePlay");
629
+ }
630
+ function spikePause() {
631
+ return call(() => requireNative().spikePause(), "spikePause");
632
+ }
633
+ function spikeSeek(positionUs) {
634
+ return call(() => requireNative().spikeSeek(positionUs), "spikeSeek");
635
+ }
636
+ function spikeState() {
637
+ return call(() => requireNative().spikeState(), "spikeState");
638
+ }
639
+ function spikeDrain(fromSeq, limit = 400) {
640
+ const page = call(() => requireNative().spikeDrain(fromSeq, limit), "spikeDrain");
641
+ return {
642
+ ok: page.ok === true,
643
+ rows: (page.rows ?? []).map((r) => ({
644
+ seq: r[0],
645
+ atNs: r[1],
646
+ positionUs: r[2],
647
+ playing: (r[3] & 1) === 1,
648
+ ready: (r[3] & 2) === 2,
649
+ epoch: r[4],
650
+ sinceEpochNs: r[5],
651
+ frameId: r[6],
652
+ frameGeneration: r[7],
653
+ frameWindow: r[8],
654
+ framePtsUs: r[9],
655
+ frameReleaseNs: r[10],
656
+ frameConsumedNs: r[11],
657
+ frameAvailableNs: r[12],
658
+ coalesced: r[13],
659
+ offered: r[14],
660
+ available: r[15],
661
+ taken: r[16],
662
+ readSpanNs: r[17],
663
+ frameMetadataNs: r[18] ?? -1
664
+ })),
665
+ nextSeq: page.nextSeq ?? fromSeq,
666
+ total: page.total ?? 0,
667
+ dropped: page.dropped ?? 0
668
+ };
669
+ }
670
+ function spikeRelease() {
671
+ return call(() => requireNative().spikeRelease(), "spikeRelease");
672
+ }
673
+
674
+ // src/playbackLifecycle.ts
675
+ function createPlaybackLifecycle(effects) {
676
+ let active = true;
677
+ let released = false;
678
+ let reason = null;
679
+ let epoch = 1;
680
+ return {
681
+ get active() {
682
+ return active;
683
+ },
684
+ get released() {
685
+ return released;
686
+ },
687
+ get reason() {
688
+ return reason;
689
+ },
690
+ get epoch() {
691
+ return epoch;
692
+ },
693
+ accepts(at) {
694
+ if (released) return false;
695
+ return active && at === epoch;
696
+ },
697
+ suspend(why) {
698
+ if (released || !active) return;
699
+ active = false;
700
+ reason = why;
701
+ epoch += 1;
702
+ effects.pause();
703
+ },
704
+ resume() {
705
+ if (released || active) return;
706
+ active = true;
707
+ reason = null;
708
+ epoch += 1;
709
+ },
710
+ teardown(why) {
711
+ if (released) return;
712
+ const wasActive = active;
713
+ released = true;
714
+ active = false;
715
+ reason = why;
716
+ epoch += 1;
717
+ if (wasActive) effects.pause();
718
+ effects.release();
719
+ }
720
+ };
721
+ }
722
+
723
+ // src/useCompositionPlayback.ts
724
+ var POLL_MS = 100;
725
+ function composedState(snapshot) {
726
+ const playbackState = snapshot.playbackState ?? 1;
727
+ return {
728
+ playing: snapshot.playing === true,
729
+ ready: playbackState === 3,
730
+ buffering: playbackState === 2,
731
+ ended: playbackState === 4,
732
+ // `released` counts releases; a positive count with nothing attached is the
733
+ // released state this means.
734
+ released: snapshot.attached !== true && (snapshot.released ?? 0) > 0,
735
+ positionUs: snapshot.positionUs ?? 0,
736
+ durationUs: snapshot.durationUs ?? 0,
737
+ /*
738
+ Zero, and honestly so.
739
+
740
+ A composition renders into the presentation surface at the size the host
741
+ gave it; it does not report a decoded size the way the video player did.
742
+ The host already knows the size it asked for, and a fabricated number here
743
+ would be worse than none.
744
+ */
745
+ width: 0,
746
+ height: 0,
747
+ error: snapshot.error ?? null
748
+ };
749
+ }
750
+ function useCompositionPlayback(options) {
751
+ const { project, mix, enabled = true } = options;
752
+ const surfaceGeneration = options.surfaceGeneration ?? 0;
753
+ const resolveSource = useRef2(options.resolveSource);
754
+ resolveSource.current = options.resolveSource;
755
+ const [state, setState] = useState2(null);
756
+ const [playing, setPlaying] = useState2(false);
757
+ const playheadRef = useRef2(options.playheadUs);
758
+ playheadRef.current = options.playheadUs;
759
+ const playlist = useMemo(() => playlistOf2(project), [project]);
760
+ const items = playlist.items;
761
+ const durationUs = useMemo(() => projectDuration(project), [project]);
762
+ const needsMix = project.audio.length > 0;
763
+ const signature = useMemo(() => {
764
+ if (needsMix && mix === null) return "";
765
+ const parts = items.map(
766
+ (i) => `${JSON.stringify(i.source)}|${i.startUs}|${i.endUs}|${i.speed}|${i.muted}`
767
+ );
768
+ const sound = mix === null ? "silent" : `${mix.uri}:${mix.durationUs}`;
769
+ return `${parts.join(";")}#${playlist.unsupported.length}@${sound}`;
770
+ }, [items, playlist.unsupported.length, mix, needsMix]);
771
+ const [unsupported, setUnsupported] = useState2([]);
772
+ const lastSeekRef = useRef2(null);
773
+ const lifecycleRef = useRef2(null);
774
+ if (lifecycleRef.current === null) {
775
+ lifecycleRef.current = createPlaybackLifecycle({
776
+ pause: () => void spikePause(),
777
+ release: () => void spikeRelease()
778
+ });
779
+ }
780
+ const lifecycle = lifecycleRef.current;
781
+ const [lifecycleEpoch, setLifecycleEpoch] = useState2(lifecycle.epoch);
782
+ const loaded = useRef2("");
783
+ useEffect2(() => {
784
+ if (!enabled || signature === "") return;
785
+ if (surfaceGeneration === 0) return;
786
+ if (loaded.current === signature) return;
787
+ const { spec, unsupported: refused } = compositionSpikeSpec(
788
+ project,
789
+ mix ?? { uri: "", durationUs: 0 },
790
+ (s) => resolveSource.current(s)
791
+ );
792
+ setUnsupported(
793
+ refused.map((reason) => ({ clipId: reason.split(":")[0] ?? "", reason }))
794
+ );
795
+ if (spec.items.length === 0) return;
796
+ const started = spikeStart(spec);
797
+ if (started.ok === true) loaded.current = signature;
798
+ }, [enabled, signature, surfaceGeneration, project, mix]);
799
+ useEffect2(() => {
800
+ return () => {
801
+ lifecycle.teardown("unmount");
802
+ };
803
+ }, [lifecycle]);
804
+ useEffect2(() => {
805
+ if (!enabled) return;
806
+ const epoch = lifecycle.epoch;
807
+ const id = setInterval(() => {
808
+ if (!lifecycle.accepts(epoch)) return;
809
+ const next = composedState(spikeState());
810
+ setState(
811
+ (previous) => previous !== null && previous.playing === next.playing && previous.ready === next.ready && previous.buffering === next.buffering && previous.ended === next.ended && previous.released === next.released && previous.error === next.error ? previous : next
812
+ );
813
+ setPlaying(next.playing);
814
+ }, POLL_MS);
815
+ return () => clearInterval(id);
816
+ }, [enabled, lifecycle, lifecycleEpoch]);
817
+ const seek = useCallback2((timeUs) => {
818
+ const at = Math.max(0, Math.round(timeUs));
819
+ lastSeekRef.current = { globalUs: at, windowIndex: 0, localUs: at };
820
+ spikeSeek(at);
821
+ }, []);
822
+ const positionUs = useCallback2(() => {
823
+ const snapshot = spikeState();
824
+ if (!snapshot.attached) return null;
825
+ const at = snapshot.positionUs;
826
+ return typeof at === "number" ? at : null;
827
+ }, []);
828
+ const play = useCallback2(() => {
829
+ const snapshot = spikeState();
830
+ const intent = playIntent({
831
+ positionUs: typeof snapshot.positionUs === "number" ? snapshot.positionUs : 0,
832
+ durationUs,
833
+ playbackState: typeof snapshot.playbackState === "number" ? snapshot.playbackState : 1,
834
+ ended: snapshot.playbackState === 4
835
+ });
836
+ if (intent.seekToUs !== null) seek(intent.seekToUs);
837
+ if (!intent.play) return intent;
838
+ if (lifecycle.released) return intent;
839
+ spikePlay();
840
+ setPlaying(true);
841
+ return intent;
842
+ }, [durationUs, lifecycle, seek]);
843
+ useEffect2(() => {
844
+ setLifecycleEpoch(lifecycle.epoch);
845
+ }, [lifecycle, surfaceGeneration]);
846
+ return {
847
+ playing,
848
+ play,
849
+ get lastSeek() {
850
+ return lastSeekRef.current;
851
+ },
852
+ positionUs,
853
+ effectiveRate: 1,
854
+ pause: useCallback2(() => {
855
+ spikePause();
856
+ setPlaying(false);
857
+ }, []),
858
+ /*
859
+ Rate is the document's, not the transport's.
860
+
861
+ Clip speed is compiled into the composition and into the mix, and a
862
+ preview-wide rate on top of it would be a second speed nobody asked for.
863
+ The contract keeps the method; this path has nothing to do with it.
864
+ */
865
+ setRate: useCallback2(() => {
866
+ }, []),
867
+ seek,
868
+ state,
869
+ items,
870
+ unsupported,
871
+ divergenceUs: 0,
872
+ lifecycle,
873
+ // One player, so there is no half to stall: the recovery this reported was
874
+ // the video player running while the audio player had stopped, or the
875
+ // reverse. @see docs/research/preview-acceptance.md
876
+ stall: null
877
+ };
878
+ }
879
+
880
+ // src/useMedia3Playback.ts
881
+ import {
882
+ playIntent as playIntent2,
883
+ playableDuration,
884
+ playlistOf as playlistOf3,
885
+ projectDuration as projectDuration2,
886
+ projectTimeToWindow,
887
+ windowToProjectTime
888
+ } from "@pictor-sdk/video-core";
889
+ import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
890
+ import { AppState as AppState2 } from "react-native";
891
+
892
+ // src/surfaceRecovery.ts
893
+ var RECOVERY_GRACE_MS = 1500;
894
+ function classifyStall(since, now) {
895
+ if (now.positionUs <= since.positionUs) return null;
896
+ if (now.pendingGeneration !== now.consumerGeneration || now.framesRejected > since.framesRejected) {
897
+ return "handshake";
898
+ }
899
+ const latched = now.framesLatched > since.framesLatched;
900
+ if (latched) {
901
+ return "resource";
902
+ }
903
+ const arrived = now.framesAvailable > since.framesAvailable;
904
+ if (arrived) return "consumer";
905
+ return "attachment";
906
+ }
907
+ function shouldRecover(since, now, graceMs = RECOVERY_GRACE_MS) {
908
+ if (now.atMs - since.atMs < graceMs) return false;
909
+ const kind = classifyStall(since, now);
910
+ return kind === "consumer" || kind === "handshake" || kind === "attachment";
911
+ }
912
+
913
+ // src/useMedia3Playback.ts
914
+ var DEFAULT_POLL_MS = 100;
915
+ var RETRY_INTERVAL_MS = 250;
916
+ function useMedia3Playback(options) {
917
+ const { project, playheadUs, enabled = true } = options;
918
+ const surfaceGeneration = options.surfaceGeneration ?? NO_SURFACE;
919
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
920
+ const [state, setState] = useState3(null);
921
+ const [playing, setPlaying] = useState3(false);
922
+ const playlist = useMemo2(() => playlistOf3(project), [project]);
923
+ const items = playlist.items;
924
+ const divergenceUs = useMemo2(
925
+ () => projectDuration2(project) - playableDuration(playlist),
926
+ [project, playlist]
927
+ );
928
+ const signature = useMemo2(
929
+ () => items.map((i) => `${JSON.stringify(i.source)}|${i.startUs}|${i.endUs}|${i.speed}|${i.muted}`).join(";") + // Unsupported clips belong in the signature even though they are not
930
+ // windows. Adding a photo between two videos leaves the video windows
931
+ // identical, so a signature built from items alone would not change and
932
+ // the player would keep playing a timeline that had grown underneath it —
933
+ // the exact divergence this is all meant to prevent.
934
+ `!${playlist.unsupported.map((u) => `${u.clipId}:${u.reason}`).join(",")}`,
935
+ [items, playlist.unsupported]
936
+ );
937
+ const playheadRef = useRef3(playheadUs);
938
+ playheadRef.current = playheadUs;
939
+ const itemsRef = useRef3(items);
940
+ itemsRef.current = items;
941
+ const session = useRef3(idlePlaylistSession());
942
+ const [retry, setRetry] = useState3(0);
943
+ useEffect3(() => {
944
+ if (!enabled) return;
945
+ const intent = { signature, generation: surfaceGeneration };
946
+ if (!shouldPrepare(session.current, intent)) return;
947
+ let ok;
948
+ try {
949
+ if (playlist.unsupported.length > 0 || items.length === 0) {
950
+ ok = Media3.setPlaylist({ items: [], unsupported: [] }) !== false;
951
+ } else {
952
+ const start = projectTimeToWindow(items, playheadRef.current) ?? {
953
+ windowIndex: 0,
954
+ positionUs: 0
955
+ };
956
+ ok = Media3.setPlaylist(playlist, start) !== false;
957
+ }
958
+ } catch {
959
+ ok = false;
960
+ }
961
+ session.current = afterPrepare(session.current, intent, ok);
962
+ }, [signature, items, playlist, enabled, surfaceGeneration, retry]);
963
+ useEffect3(() => {
964
+ if (!enabled) return;
965
+ if (!session.current.pending) return;
966
+ const id = setInterval(() => setRetry((n) => n + 1), RETRY_INTERVAL_MS);
967
+ return () => clearInterval(id);
968
+ }, [enabled, retry, surfaceGeneration, signature]);
969
+ const stallRef = useRef3(null);
970
+ const surfaceGenerationRef = useRef3(surfaceGeneration ?? 0);
971
+ surfaceGenerationRef.current = surfaceGeneration ?? 0;
972
+ const lifecycleRef = useRef3(null);
973
+ if (lifecycleRef.current === null) {
974
+ lifecycleRef.current = createPlaybackLifecycle({
975
+ pause: () => Media3.pause(),
976
+ release: () => Media3.release()
977
+ });
978
+ }
979
+ const lifecycle = lifecycleRef.current;
980
+ const [lifecycleEpoch, setLifecycleEpoch] = useState3(lifecycle.epoch);
981
+ useEffect3(() => {
982
+ const subscription = AppState2.addEventListener("change", (next) => {
983
+ if (next === "active") {
984
+ lifecycle.resume();
985
+ setLifecycleEpoch(lifecycle.epoch);
986
+ return;
987
+ }
988
+ lifecycle.suspend("background");
989
+ setLifecycleEpoch(lifecycle.epoch);
990
+ setPlaying(false);
991
+ });
992
+ return () => subscription.remove();
993
+ }, [lifecycle]);
994
+ useEffect3(() => {
995
+ return () => {
996
+ lifecycle.teardown("unmount");
997
+ };
998
+ }, [lifecycle]);
999
+ const healthy = useRef3(null);
1000
+ const [stall, setStall] = useState3(null);
1001
+ const lastMeaning = useRef3("");
1002
+ useEffect3(() => {
1003
+ if (!enabled) return;
1004
+ const epoch = lifecycle.epoch;
1005
+ const id = setInterval(() => {
1006
+ if (!lifecycle.accepts(epoch)) return;
1007
+ const snapshot = Media3.state();
1008
+ const meaning = [
1009
+ snapshot.playing,
1010
+ snapshot.ready,
1011
+ snapshot.buffering,
1012
+ snapshot.ended,
1013
+ snapshot.released,
1014
+ snapshot.width,
1015
+ snapshot.height,
1016
+ snapshot.windowIndex,
1017
+ snapshot.windowCount,
1018
+ snapshot.errorCount,
1019
+ snapshot.errorMessage,
1020
+ snapshot.rateConstrained
1021
+ ].join("|");
1022
+ if (meaning !== lastMeaning.current) {
1023
+ lastMeaning.current = meaning;
1024
+ setState(snapshot);
1025
+ setPlaying(snapshot.playing);
1026
+ }
1027
+ const sample = {
1028
+ atMs: Date.now(),
1029
+ positionUs: snapshot.windowPositionUs,
1030
+ framesOffered: snapshot.framesOffered,
1031
+ framesAvailable: snapshot.framesAvailable,
1032
+ // Latched is not reported directly: what arrived, less what is still
1033
+ // waiting, is the count the consumer has actually taken.
1034
+ framesLatched: snapshot.framesAvailable - snapshot.pendingDepth,
1035
+ framesRejected: snapshot.framesRejected,
1036
+ pendingGeneration: snapshot.pendingGeneration,
1037
+ consumerGeneration: surfaceGenerationRef.current
1038
+ };
1039
+ const since = healthy.current;
1040
+ if (since === null) {
1041
+ healthy.current = sample;
1042
+ } else if (classifyStall(since, sample) === null) {
1043
+ healthy.current = sample;
1044
+ if (stallRef.current !== null) setStall(null);
1045
+ } else if (shouldRecover(since, sample)) {
1046
+ const kind = classifyStall(since, sample);
1047
+ healthy.current = sample;
1048
+ stallRef.current = kind;
1049
+ setStall(kind);
1050
+ session.current = { ...session.current, pending: true };
1051
+ setRetry((n) => n + 1);
1052
+ }
1053
+ if (!snapshot.playing) return;
1054
+ const projectUs = windowToProjectTime(
1055
+ itemsRef.current,
1056
+ snapshot.windowIndex,
1057
+ snapshot.windowPositionUs
1058
+ );
1059
+ if (projectUs !== null) options.onPositionUs?.(projectUs);
1060
+ }, pollMs);
1061
+ return () => clearInterval(id);
1062
+ }, [enabled, pollMs, lifecycle, lifecycleEpoch]);
1063
+ const lastSeekRef = useRef3(null);
1064
+ const seek = useCallback3((timeUs) => {
1065
+ const target = projectTimeToWindow(itemsRef.current, timeUs);
1066
+ if (!target) return;
1067
+ lastSeekRef.current = {
1068
+ globalUs: Math.round(timeUs),
1069
+ windowIndex: target.windowIndex,
1070
+ localUs: target.positionUs
1071
+ };
1072
+ Media3.seekToWindow(target.windowIndex, target.positionUs);
1073
+ }, []);
1074
+ const positionUs = useCallback3(() => {
1075
+ const snapshot = Media3.state();
1076
+ if (snapshot.released || itemsRef.current.length === 0) return null;
1077
+ return windowToProjectTime(
1078
+ itemsRef.current,
1079
+ snapshot.windowIndex,
1080
+ snapshot.windowPositionUs
1081
+ );
1082
+ }, []);
1083
+ const durationUsRef = useRef3(0);
1084
+ durationUsRef.current = projectDuration2(project);
1085
+ const play = useCallback3(() => {
1086
+ const snapshot = Media3.state();
1087
+ const intent = playIntent2({
1088
+ positionUs: windowToProjectTime(
1089
+ itemsRef.current,
1090
+ snapshot.windowIndex,
1091
+ snapshot.windowPositionUs
1092
+ ) ?? 0,
1093
+ durationUs: durationUsRef.current,
1094
+ playbackState: snapshot.playbackState,
1095
+ ended: snapshot.ended
1096
+ });
1097
+ if (intent.seekToUs !== null) {
1098
+ const target = projectTimeToWindow(itemsRef.current, intent.seekToUs);
1099
+ if (target) Media3.seekToWindow(target.windowIndex, target.positionUs);
1100
+ }
1101
+ if (!intent.play) return intent;
1102
+ if (lifecycle.released) return intent;
1103
+ Media3.play();
1104
+ setPlaying(true);
1105
+ return intent;
1106
+ }, [lifecycle]);
1107
+ return {
1108
+ playing,
1109
+ play,
1110
+ get lastSeek() {
1111
+ return lastSeekRef.current;
1112
+ },
1113
+ positionUs,
1114
+ effectiveRate: state?.effectiveRate ?? 1,
1115
+ pause: useCallback3(() => {
1116
+ Media3.pause();
1117
+ setPlaying(false);
1118
+ }, []),
1119
+ setRate: useCallback3((rate) => Media3.setRate(rate), []),
1120
+ seek,
1121
+ state,
1122
+ items,
1123
+ unsupported: playlist.unsupported,
1124
+ divergenceUs,
1125
+ lifecycle,
1126
+ stall
1127
+ };
1128
+ }
1129
+
1130
+ // src/thumbnailProvider.ts
1131
+ import {
1132
+ createThumbnailPolicy,
1133
+ thumbnailKey
1134
+ } from "@pictor-sdk/video-core";
1135
+ var DEFAULT_CAPACITY = 240;
1136
+ var DEFAULT_POLL_MS2 = 16;
1137
+ function createAndroidThumbnailProvider(options = {}) {
1138
+ const policy = createThumbnailPolicy({
1139
+ capacity: options.capacity ?? DEFAULT_CAPACITY
1140
+ });
1141
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS2;
1142
+ const tokens = /* @__PURE__ */ new Map();
1143
+ function uriOf2(source) {
1144
+ if (typeof source === "string") return source;
1145
+ if (source && typeof source === "object" && "uri" in source) {
1146
+ const uri = source.uri;
1147
+ if (typeof uri === "string") return uri;
1148
+ }
1149
+ throw new Error(
1150
+ `@pictor-sdk/video-android: a thumbnail source is not a URI. Normalise host media to { kind: 'uri', uri } first. Received: ${JSON.stringify(source)}`
1151
+ );
1152
+ }
1153
+ return {
1154
+ async request(request, signal) {
1155
+ const key = thumbnailKey(request);
1156
+ const cached = policy.get(request);
1157
+ if (cached !== void 0) {
1158
+ return { image: cached, sourceTimeUs: request.sourceTimeUs, size: { width: 0, height: 0 } };
1159
+ }
1160
+ if (signal?.aborted) return null;
1161
+ const native = requireNative();
1162
+ policy.begin(request);
1163
+ const token = native.thumbnailRequest(
1164
+ uriOf2(request.source),
1165
+ request.sourceTimeUs,
1166
+ request.widthPx
1167
+ );
1168
+ tokens.set(key, token);
1169
+ const stop = () => {
1170
+ native.thumbnailCancel(token);
1171
+ tokens.delete(key);
1172
+ policy.cancel(request);
1173
+ };
1174
+ signal?.addEventListener("abort", stop, { once: true });
1175
+ try {
1176
+ for (; ; ) {
1177
+ if (signal?.aborted) return null;
1178
+ const data = native.thumbnailTake(token);
1179
+ if (data !== null && data !== void 0) {
1180
+ tokens.delete(key);
1181
+ const evicted = policy.store(request, `data:image/jpeg;base64,${data}`);
1182
+ void evicted;
1183
+ return {
1184
+ image: policy.get(request),
1185
+ sourceTimeUs: request.sourceTimeUs,
1186
+ size: { width: request.widthPx, height: 0 }
1187
+ };
1188
+ }
1189
+ const error = native.thumbnailError(token);
1190
+ if (error !== null && error !== void 0) {
1191
+ tokens.delete(key);
1192
+ policy.cancel(request);
1193
+ return null;
1194
+ }
1195
+ if (!native.thumbnailRunning(token)) {
1196
+ tokens.delete(key);
1197
+ policy.cancel(request);
1198
+ return null;
1199
+ }
1200
+ await new Promise((resolve) => setTimeout(() => resolve(), pollMs));
1201
+ }
1202
+ } finally {
1203
+ signal?.removeEventListener("abort", stop);
1204
+ }
1205
+ },
1206
+ releaseSource(source) {
1207
+ policy.evictSource(source);
1208
+ requireNative().thumbnailReleaseSource(uriOf2(source));
1209
+ },
1210
+ release() {
1211
+ for (const token of tokens.values()) requireNative().thumbnailCancel(token);
1212
+ tokens.clear();
1213
+ policy.clear();
1214
+ requireNative().thumbnailReleaseAll();
1215
+ }
1216
+ };
1217
+ }
1218
+ function thumbnailCounters() {
1219
+ return requireNative().thumbnailCounters();
1220
+ }
1221
+
1222
+ // src/convergence.ts
1223
+ var CONVERGED_RUN = 8;
1224
+ var MAX_RECOVERY_SAMPLES = 600;
1225
+ function beginPhase(phase) {
1226
+ return {
1227
+ phase,
1228
+ samples: [],
1229
+ run: 0,
1230
+ worstUs: 0,
1231
+ recoverySamples: 0,
1232
+ converged: phase === "steady",
1233
+ timedOut: false
1234
+ };
1235
+ }
1236
+ function observe(state, offsetUs, boundUs) {
1237
+ if (state.converged) {
1238
+ return { state, bucket: "steady" };
1239
+ }
1240
+ const magnitude = Math.abs(offsetUs);
1241
+ const next = {
1242
+ ...state,
1243
+ samples: [...state.samples, offsetUs],
1244
+ recoverySamples: state.recoverySamples + 1,
1245
+ worstUs: Math.max(state.worstUs, magnitude),
1246
+ run: magnitude <= boundUs ? state.run + 1 : 0
1247
+ };
1248
+ if (next.run >= CONVERGED_RUN) {
1249
+ next.converged = true;
1250
+ } else if (next.recoverySamples >= MAX_RECOVERY_SAMPLES) {
1251
+ next.converged = true;
1252
+ next.timedOut = true;
1253
+ }
1254
+ return { state: next, bucket: state.phase };
1255
+ }
1256
+ function recoveryFrames(state) {
1257
+ return state.converged ? state.recoverySamples : null;
1258
+ }
1259
+
1260
+ // src/exportJob.ts
1261
+ import { VideoExportCancelled } from "@pictor-sdk/video-core";
1262
+ var EXPORT_STAGES = [
1263
+ "resolving",
1264
+ "overlays",
1265
+ "shards",
1266
+ "cadence",
1267
+ "audio",
1268
+ "concatenating",
1269
+ "finalising",
1270
+ "publishing"
1271
+ ];
1272
+ var counter = 0;
1273
+ function createExportJobs(options) {
1274
+ const now = options.now ?? (() => Date.now());
1275
+ let active = null;
1276
+ let cancelledAt = null;
1277
+ let cancelRequested = false;
1278
+ let settled = false;
1279
+ let accepted = 0;
1280
+ let succeeded = 0;
1281
+ let cancelled = 0;
1282
+ let failed = 0;
1283
+ const clear = () => {
1284
+ active = null;
1285
+ cancelRequested = false;
1286
+ cancelledAt = null;
1287
+ settled = true;
1288
+ };
1289
+ return {
1290
+ get active() {
1291
+ return active;
1292
+ },
1293
+ get counts() {
1294
+ return {
1295
+ accepted,
1296
+ succeeded,
1297
+ cancelled,
1298
+ failed,
1299
+ active: active === null ? 0 : 1
1300
+ };
1301
+ },
1302
+ async run(body) {
1303
+ if (active !== null) {
1304
+ throw new Error(
1305
+ `createMedia3VideoExporter: an export is already running (${active}); cancel it before starting another`
1306
+ );
1307
+ }
1308
+ counter += 1;
1309
+ accepted += 1;
1310
+ const id = `export_${now().toString(36)}_${counter.toString(36)}`;
1311
+ active = id;
1312
+ cancelRequested = false;
1313
+ cancelledAt = null;
1314
+ settled = false;
1315
+ const job = {
1316
+ id,
1317
+ checkpoint(stage) {
1318
+ if (cancelRequested) {
1319
+ cancelledAt = stage;
1320
+ throw new VideoExportCancelled(id, stage);
1321
+ }
1322
+ },
1323
+ progress(value, deliver) {
1324
+ if (settled || cancelRequested) return;
1325
+ deliver(value);
1326
+ }
1327
+ };
1328
+ try {
1329
+ const result = await body(job);
1330
+ if (cancelRequested) {
1331
+ throw new VideoExportCancelled(id, cancelledAt ?? "finalising");
1332
+ }
1333
+ clear();
1334
+ succeeded += 1;
1335
+ return result;
1336
+ } catch (error) {
1337
+ const wasCancelled = cancelRequested;
1338
+ const stage = cancelledAt ?? "finalising";
1339
+ clear();
1340
+ if (wasCancelled) cancelled += 1;
1341
+ else failed += 1;
1342
+ if (wasCancelled) {
1343
+ if (error instanceof VideoExportCancelled) throw error;
1344
+ throw new VideoExportCancelled(id, stage);
1345
+ }
1346
+ throw error;
1347
+ }
1348
+ },
1349
+ cancel() {
1350
+ if (active === null || cancelRequested) return;
1351
+ cancelRequested = true;
1352
+ options.cancelNative(active);
1353
+ }
1354
+ };
1355
+ }
1356
+
1357
+ // src/mixSources.ts
1358
+ function mixSpanContext(project, spanId) {
1359
+ const isClip = project.clips.some((clip) => clip.id === spanId);
1360
+ const isAudio = project.audio.some((item) => item.id === spanId);
1361
+ if (isClip === isAudio) return null;
1362
+ return isClip ? { kind: "clip", clipId: spanId } : { kind: "audio", audioId: spanId };
1363
+ }
1364
+ function resolveMixSources(project, program, resolveSource) {
1365
+ const uris = {};
1366
+ const unresolved = [];
1367
+ for (const span of program.spans) {
1368
+ if (span.silent || span.source === null) continue;
1369
+ const context = mixSpanContext(project, span.id);
1370
+ if (context === null) {
1371
+ unresolved.push(
1372
+ `audio span '${span.id}' matches no clip or audio item, so it has no owner to resolve against`
1373
+ );
1374
+ continue;
1375
+ }
1376
+ const uri = resolveSource(span.source, context);
1377
+ if (typeof uri !== "string" || uri.length === 0) {
1378
+ unresolved.push(`audio span '${span.id}' could not be resolved to a readable source`);
1379
+ continue;
1380
+ }
1381
+ uris[span.id] = uri;
1382
+ }
1383
+ return { uris, unresolved };
1384
+ }
1385
+
1386
+ // src/exportDestination.ts
1387
+ function describeTarget(path) {
1388
+ const trimmed = path.replace(/\/+$/, "");
1389
+ const name = trimmed.slice(trimmed.lastIndexOf("/") + 1);
1390
+ return name.length > 0 ? name : "(unnamed)";
1391
+ }
1392
+ var KNOWN_SCHEMES = {
1393
+ "content:": "a content uri is not a filesystem path, so it cannot be published atomically",
1394
+ "http:": "a remote destination cannot be written to directly",
1395
+ "https:": "a remote destination cannot be written to directly",
1396
+ "android.resource:": "app resources are read-only",
1397
+ "asset:": "bundled assets are read-only"
1398
+ };
1399
+ function resolveDestination(destination) {
1400
+ if (destination === void 0) return { destination: { kind: "temporary" }, reasons: [] };
1401
+ if (destination.kind === "temporary") return { destination: { kind: "temporary" }, reasons: [] };
1402
+ if (destination.kind !== "file") {
1403
+ return {
1404
+ destination: null,
1405
+ reasons: [`destination kind '${String(destination.kind)}' is not supported`]
1406
+ };
1407
+ }
1408
+ const uri = destination.uri;
1409
+ if (typeof uri !== "string" || uri.trim().length === 0) {
1410
+ return { destination: null, reasons: ["the file destination has no uri"] };
1411
+ }
1412
+ const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.exec(uri)?.[0]?.toLowerCase();
1413
+ if (scheme !== void 0 && scheme !== "file:") {
1414
+ const known = KNOWN_SCHEMES[scheme];
1415
+ return {
1416
+ destination: null,
1417
+ reasons: [
1418
+ known !== void 0 ? `destination scheme '${scheme}' is not supported: ${known}` : `destination scheme '${scheme}' is not supported; only file: is`
1419
+ ]
1420
+ };
1421
+ }
1422
+ if (scheme === void 0) {
1423
+ return { destination: null, reasons: ["the file destination must be a file: uri"] };
1424
+ }
1425
+ let path;
1426
+ try {
1427
+ const withoutScheme = uri.slice("file:".length);
1428
+ const rest = withoutScheme.startsWith("//") ? withoutScheme.slice(2) : withoutScheme;
1429
+ if (withoutScheme.startsWith("//") && !rest.startsWith("/")) {
1430
+ return {
1431
+ destination: null,
1432
+ reasons: ["the file destination names a remote host, which is not supported"]
1433
+ };
1434
+ }
1435
+ path = decodeURIComponent(rest);
1436
+ } catch {
1437
+ return { destination: null, reasons: ["the file destination uri is malformed"] };
1438
+ }
1439
+ if (!path.startsWith("/")) {
1440
+ return { destination: null, reasons: ["the file destination must be an absolute path"] };
1441
+ }
1442
+ if (path.endsWith("/")) {
1443
+ return {
1444
+ destination: null,
1445
+ reasons: ["the file destination is a directory; it must name the file to write"]
1446
+ };
1447
+ }
1448
+ if (path.includes("\0")) {
1449
+ return { destination: null, reasons: ["the file destination uri is malformed"] };
1450
+ }
1451
+ if (path.split("/").some((segment) => segment === "..")) {
1452
+ return { destination: null, reasons: ['the file destination must not contain ".."'] };
1453
+ }
1454
+ const name = describeTarget(path);
1455
+ if (name === "." || name === "(unnamed)") {
1456
+ return {
1457
+ destination: null,
1458
+ reasons: ["the file destination is a directory; it must name the file to write"]
1459
+ };
1460
+ }
1461
+ return {
1462
+ destination: { kind: "file", path, overwrite: destination.overwrite === true },
1463
+ reasons: []
1464
+ };
1465
+ }
1466
+
1467
+ // src/exportProgress.ts
1468
+ var STALL_GRACE_MS = 7e3;
1469
+ var MAX_OBSERVED_HEARTBEAT_GAP_MS = 2046;
1470
+ var AUDIO_UNIT = 1 / 512;
1471
+ var MUX_UNITS = 24;
1472
+ var PUBLISH_UNITS = 8;
1473
+ function planProgressWeights(input) {
1474
+ const shardUnits = input.shards.map((s) => Math.max(0, s.frames));
1475
+ const audioUnits = input.audioSamples > 0 ? input.audioSamples * AUDIO_UNIT : 0;
1476
+ const total = shardUnits.reduce((a, b) => a + b, 0) + audioUnits + MUX_UNITS + PUBLISH_UNITS;
1477
+ return {
1478
+ shardWeights: shardUnits.map((u) => u / total),
1479
+ audioWeight: audioUnits / total,
1480
+ muxWeight: MUX_UNITS / total,
1481
+ publishWeight: PUBLISH_UNITS / total,
1482
+ durationUs: Math.max(0, input.durationUs),
1483
+ shardFrames: shardUnits,
1484
+ audioSamples: Math.max(0, input.audioSamples)
1485
+ };
1486
+ }
1487
+ function createProgressReporter(options) {
1488
+ const now = options.now ?? (() => Date.now());
1489
+ const throttleMs = options.throttleMs ?? 120;
1490
+ const plan = options.plan;
1491
+ const shardDone = plan.shardWeights.map(() => 0);
1492
+ let audioDone = 0;
1493
+ let muxDone = 0;
1494
+ let publishDone = 0;
1495
+ let highest = 0;
1496
+ let began = false;
1497
+ let finished = false;
1498
+ let stopped = false;
1499
+ let lastEmitMs = Number.NEGATIVE_INFINITY;
1500
+ let batching = 0;
1501
+ let batchForce = false;
1502
+ const fraction = () => {
1503
+ let sum = 0;
1504
+ for (let i = 0; i < shardDone.length; i += 1) {
1505
+ sum += plan.shardWeights[i] * shardDone[i];
1506
+ }
1507
+ return sum + plan.audioWeight * audioDone + plan.muxWeight * muxDone + plan.publishWeight * publishDone;
1508
+ };
1509
+ const emit = (value, force) => {
1510
+ if (stopped || !began) return;
1511
+ if (batching > 0) {
1512
+ batchForce = batchForce || force;
1513
+ return;
1514
+ }
1515
+ const next = Math.max(highest, Math.min(1, value));
1516
+ const at = now();
1517
+ if (!force && at - lastEmitMs < throttleMs) return;
1518
+ if (!force && began && next === highest) return;
1519
+ highest = next;
1520
+ lastEmitMs = at;
1521
+ try {
1522
+ options.onProgress?.({
1523
+ progress: highest,
1524
+ time: Math.min(plan.durationUs, Math.round(plan.durationUs * highest))
1525
+ });
1526
+ } catch {
1527
+ }
1528
+ };
1529
+ return {
1530
+ begin() {
1531
+ if (began || stopped) return;
1532
+ began = true;
1533
+ emit(0, true);
1534
+ },
1535
+ shard(index, framesDone) {
1536
+ if (finished || stopped) return;
1537
+ if (index < 0 || index >= shardDone.length) return;
1538
+ const total = Math.max(1, plan.shardFrames[index] ?? 1);
1539
+ shardDone[index] = Math.max(shardDone[index], Math.min(1, framesDone / total));
1540
+ emit(fraction(), false);
1541
+ },
1542
+ audio(samplesDone) {
1543
+ if (finished || stopped) return;
1544
+ const total = Math.max(1, plan.audioSamples);
1545
+ audioDone = Math.max(audioDone, Math.min(1, samplesDone / total));
1546
+ emit(fraction(), false);
1547
+ },
1548
+ muxed() {
1549
+ if (finished || stopped) return;
1550
+ muxDone = 1;
1551
+ emit(fraction(), true);
1552
+ },
1553
+ complete() {
1554
+ if (finished || stopped) return;
1555
+ finished = true;
1556
+ publishDone = 1;
1557
+ emit(1, true);
1558
+ },
1559
+ batch(body) {
1560
+ batching += 1;
1561
+ try {
1562
+ body();
1563
+ } finally {
1564
+ batching -= 1;
1565
+ }
1566
+ const force = batchForce;
1567
+ batchForce = false;
1568
+ emit(finished ? 1 : fraction(), force);
1569
+ },
1570
+ stop() {
1571
+ stopped = true;
1572
+ }
1573
+ };
1574
+ }
1575
+ function createStallWatchdog(options = {}) {
1576
+ const thresholdMs = options.thresholdMs ?? STALL_GRACE_MS;
1577
+ let lastStage = null;
1578
+ let lastUnits = 0;
1579
+ let lastAtMs = 0;
1580
+ let fired = false;
1581
+ return {
1582
+ advanced(stage, units, atMs) {
1583
+ lastStage = stage;
1584
+ lastUnits = units;
1585
+ lastAtMs = atMs;
1586
+ },
1587
+ stalledFor(atMs) {
1588
+ if (fired || lastStage === null) return null;
1589
+ const idleMs = atMs - lastAtMs;
1590
+ if (idleMs < thresholdMs) return null;
1591
+ fired = true;
1592
+ return { stage: lastStage, units: lastUnits, idleMs };
1593
+ }
1594
+ };
1595
+ }
1596
+
1597
+ // src/exportProgressPump.ts
1598
+ function stageOf(snapshot, plan) {
1599
+ if (snapshot.muxed) return "publishing";
1600
+ const shardsDone = plan.shardFrames.every(
1601
+ (total, index) => (snapshot.shards[index] ?? 0) >= total
1602
+ );
1603
+ if (!shardsDone) return "shards";
1604
+ if (snapshot.audioPlanned > 0 && snapshot.audioSamples < snapshot.audioPlanned) return "audio";
1605
+ return "concatenating";
1606
+ }
1607
+ function createProgressPump(options) {
1608
+ const now = options.now ?? (() => Date.now());
1609
+ const pollMs = options.pollMs ?? 125;
1610
+ const plan = options.plan;
1611
+ const reporter = createProgressReporter({
1612
+ plan,
1613
+ durationUs: options.durationUs,
1614
+ onProgress: options.onProgress,
1615
+ // The reporter's own throttle is the poll interval: a tick that finds
1616
+ // nothing new should not be able to spend a crossing saying so.
1617
+ throttleMs: pollMs,
1618
+ now
1619
+ });
1620
+ const watchdog = createStallWatchdog({ thresholdMs: options.thresholdMs ?? STALL_GRACE_MS });
1621
+ let lastBeats = -1;
1622
+ let stopped = false;
1623
+ let stalled = false;
1624
+ return {
1625
+ pollMs,
1626
+ begin() {
1627
+ reporter.begin();
1628
+ watchdog.advanced("resolving", 0, now());
1629
+ },
1630
+ tick() {
1631
+ if (stopped || stalled) return;
1632
+ const snapshot = options.read();
1633
+ reporter.batch(() => {
1634
+ for (let i = 0; i < plan.shardFrames.length; i += 1) {
1635
+ reporter.shard(i, snapshot.shards[i] ?? 0);
1636
+ }
1637
+ if (snapshot.audioPlanned > 0 && plan.audioSamples > 0) {
1638
+ const fraction = Math.min(1, snapshot.audioSamples / snapshot.audioPlanned);
1639
+ reporter.audio(plan.audioSamples * fraction);
1640
+ }
1641
+ if (snapshot.muxed) reporter.muxed();
1642
+ });
1643
+ const at = now();
1644
+ if (snapshot.beats !== lastBeats) {
1645
+ lastBeats = snapshot.beats;
1646
+ watchdog.advanced(stageOf(snapshot, plan), snapshot.beats, at);
1647
+ }
1648
+ const verdict = watchdog.stalledFor(at);
1649
+ if (verdict) {
1650
+ stalled = true;
1651
+ options.onStall(verdict);
1652
+ }
1653
+ },
1654
+ complete() {
1655
+ if (stopped) return;
1656
+ reporter.complete();
1657
+ },
1658
+ stop() {
1659
+ stopped = true;
1660
+ reporter.stop();
1661
+ }
1662
+ };
1663
+ }
1664
+
1665
+ // src/exporter.ts
1666
+ import {
1667
+ VideoExportCancelled as VideoExportCancelled2,
1668
+ VideoExportFailed,
1669
+ VideoExportStalled,
1670
+ compileAudioMixProgram,
1671
+ compileVideoSceneProgram,
1672
+ isPlayableWindow,
1673
+ layout,
1674
+ planClipShards,
1675
+ projectDuration as projectDuration3,
1676
+ validateAudioMixProgram,
1677
+ validateProject,
1678
+ VIDEO_SCENE_PROGRAM_VERSION
1679
+ } from "@pictor-sdk/video-core";
1680
+ function nativeModule() {
1681
+ if (!NativePictorVideo_default) {
1682
+ throw new VideoExportRejected(["the Pictor video native module is not installed"]);
1683
+ }
1684
+ return NativePictorVideo_default;
1685
+ }
1686
+ var VideoExportRejected = class extends Error {
1687
+ constructor(reasons) {
1688
+ super(`export rejected: ${reasons.join("; ")}`);
1689
+ this.name = "VideoExportRejected";
1690
+ this.reasons = reasons;
1691
+ }
1692
+ };
1693
+ var DEFAULT_FPS = 30;
1694
+ function describeUnsupported(project, spec) {
1695
+ const reasons = [];
1696
+ for (const clip of project.clips) {
1697
+ if (clip.kind === "photo") {
1698
+ reasons.push(`clip '${clip.id}' is a photo, which this exporter does not encode yet`);
1699
+ continue;
1700
+ }
1701
+ }
1702
+ if (spec.format !== void 0 && spec.format !== "mp4") {
1703
+ reasons.push(`format '${spec.format}' is not supported; this exporter writes mp4`);
1704
+ }
1705
+ if (spec.scale !== void 0 && spec.scale !== 1) {
1706
+ reasons.push(`scale ${spec.scale} is not supported yet; only 1 is`);
1707
+ }
1708
+ if (spec.fps !== void 0 && spec.fps !== DEFAULT_FPS) {
1709
+ reasons.push(`fps ${spec.fps} is not supported yet; only ${DEFAULT_FPS} is`);
1710
+ }
1711
+ if (spec.bitrate !== void 0) {
1712
+ reasons.push("bitrate is not supported yet; the encoder default is used");
1713
+ }
1714
+ return reasons;
1715
+ }
1716
+ function createMedia3VideoExporter(options) {
1717
+ const jobs = createExportJobs({
1718
+ cancelNative: (jobId) => {
1719
+ nativeModule().exportCancelJob(jobId);
1720
+ }
1721
+ });
1722
+ return {
1723
+ async export(project, spec, onProgress) {
1724
+ return jobs.run(async (job) => {
1725
+ job.checkpoint("resolving");
1726
+ const issues = validateProject(project);
1727
+ if (issues.length > 0) {
1728
+ throw new VideoExportRejected(issues.map((issue) => issue.message));
1729
+ }
1730
+ const unsupported = describeUnsupported(project, spec);
1731
+ if (unsupported.length > 0) throw new VideoExportRejected(unsupported);
1732
+ const decision = resolveDestination(spec.destination);
1733
+ if (decision.destination === null) throw new VideoExportRejected(decision.reasons);
1734
+ const destination = decision.destination;
1735
+ const playable = project.clips.filter((clip) => isPlayableWindow(clip));
1736
+ if (playable.length === 0) throw new VideoExportRejected(["the project has no playable clips"]);
1737
+ if (!(projectDuration3(project) > 0)) {
1738
+ throw new VideoExportRejected(["the project has no duration"]);
1739
+ }
1740
+ const uris = [];
1741
+ const unresolved = [];
1742
+ for (const clip of playable) {
1743
+ const uri = options.resolveSource(clip.source, { kind: "clip", clipId: clip.id });
1744
+ if (typeof uri !== "string" || uri.length === 0) {
1745
+ unresolved.push(`clip '${clip.id}' could not be resolved to a readable source`);
1746
+ continue;
1747
+ }
1748
+ uris.push(uri);
1749
+ }
1750
+ if (unresolved.length > 0) throw new VideoExportRejected(unresolved);
1751
+ const fps = DEFAULT_FPS;
1752
+ const placements = layout(project);
1753
+ const requests = playable.map((clip) => ({
1754
+ sourceStartUs: clip.sourceIn,
1755
+ sourceEndUs: clip.sourceOut,
1756
+ speed: clip.speed,
1757
+ sourceFps: clip.metadata.fps ?? fps,
1758
+ targetFps: fps
1759
+ }));
1760
+ const plan = planClipShards(requests, fps);
1761
+ const program = compileVideoSceneProgram(project);
1762
+ if (program.segments.length === 0) {
1763
+ throw new VideoExportRejected(["the compiled program has no segments"]);
1764
+ }
1765
+ void placements;
1766
+ const named = /* @__PURE__ */ new Set();
1767
+ for (const segment of program.segments) {
1768
+ for (const command of segment.commands) {
1769
+ if (command.op === "overlay") named.add(command.overlayId);
1770
+ }
1771
+ }
1772
+ const overlayPlan = options.prepareOverlays ? options.prepareOverlays(program, project.size, 1) : {
1773
+ resources: [],
1774
+ pending: [],
1775
+ invalid: [...named].map((overlayId) => ({
1776
+ overlayId,
1777
+ kind: "unknown",
1778
+ reason: "no prepareOverlays was supplied, so this overlay has no portable form"
1779
+ }))
1780
+ };
1781
+ job.checkpoint("overlays");
1782
+ const stagedNow = overlayPlan.resources.map((resource) => resource.file);
1783
+ if (overlayPlan.pending.length > 0) {
1784
+ options.releaseResources?.(stagedNow);
1785
+ throw new VideoExportRejected(
1786
+ overlayPlan.pending.map(
1787
+ (id) => `overlay '${id}' is still preparing; export would omit it`
1788
+ )
1789
+ );
1790
+ }
1791
+ if (overlayPlan.invalid.length > 0) {
1792
+ options.releaseResources?.(stagedNow);
1793
+ throw new VideoExportRejected(
1794
+ overlayPlan.invalid.map(
1795
+ (bad) => `overlay '${bad.overlayId}' (${bad.kind}): ${bad.reason}`
1796
+ )
1797
+ );
1798
+ }
1799
+ const resources = overlayPlan.resources;
1800
+ const shards = plan.map((shard, index) => {
1801
+ const clip = playable[index];
1802
+ const cadence = cadenceFor(shard.plan.frames);
1803
+ return {
1804
+ uri: uris[index],
1805
+ startMs: Math.round(clip.sourceIn / 1e3),
1806
+ endMs: Math.round(clip.sourceOut / 1e3),
1807
+ speed: clip.speed,
1808
+ timelineOffsetUs: shard.timelineOffsetUs,
1809
+ /*
1810
+ The shard's own audio is dropped and replaced with generated silence.
1811
+
1812
+ Not because anything wants that silence — the final mux takes only
1813
+ the video track from this pipeline — but because Media3 drives its
1814
+ speed path through audio, and a video-only item at any rate other
1815
+ than 1 stalls with "no output sample written". So the track exists to
1816
+ satisfy the re-timing and is discarded.
1817
+
1818
+ What matters is that the *source's* audio never enters this pipeline:
1819
+ a per-shard audio track has to be re-encoded to concatenate, which is
1820
+ what put a second generation on everything the user hears.
1821
+ */
1822
+ forceAudioTrack: true,
1823
+ removeAudio: true,
1824
+ emitsPerInput: cadence.emitsPerInput,
1825
+ outputPtsUs: cadence.outputPtsUs,
1826
+ spanUs: Math.round(shard.plan.frameCount * 1e6 / fps)
1827
+ };
1828
+ });
1829
+ const mixProgram = compileAudioMixProgram(project);
1830
+ const mixProblems = validateAudioMixProgram(mixProgram);
1831
+ if (mixProblems.length > 0) throw new VideoExportRejected(mixProblems);
1832
+ const resolved = resolveMixSources(project, mixProgram, options.resolveSource);
1833
+ const mixUris = resolved.uris;
1834
+ if (resolved.unresolved.length > 0) throw new VideoExportRejected(resolved.unresolved);
1835
+ job.checkpoint("resolving");
1836
+ job.checkpoint("overlays");
1837
+ const staged = resources.map((resource) => resource.file);
1838
+ const durationUs = projectDuration3(project);
1839
+ const progressPlan = planProgressWeights({
1840
+ shards: plan.map((shard) => ({ frames: shard.plan.frameCount })),
1841
+ audioSamples: mixProgram.spans.some((span) => !span.silent) ? Math.max(0, mixProgram.durationUs) : 0,
1842
+ durationUs
1843
+ });
1844
+ const stalled = { at: null };
1845
+ const pump = createProgressPump({
1846
+ plan: progressPlan,
1847
+ durationUs,
1848
+ read: () => JSON.parse(nativeModule().exportProgressSnapshot()),
1849
+ // Delivered through the job, so nothing arrives after settlement: a
1850
+ // host that has already shown a result must not be told 90%.
1851
+ onProgress: onProgress ? (value) => {
1852
+ job.progress(value.progress, () => onProgress(value));
1853
+ } : void 0,
1854
+ onStall: (verdict) => {
1855
+ stalled.at = { stage: String(verdict.stage), idleMs: verdict.idleMs };
1856
+ nativeModule().exportCancelJob(job.id);
1857
+ },
1858
+ thresholdMs: options.stallGraceMs,
1859
+ pollMs: options.progressPollMs
1860
+ });
1861
+ pump.begin();
1862
+ const ticker = setInterval(() => {
1863
+ pump.tick();
1864
+ }, pump.pollMs);
1865
+ let outcome;
1866
+ try {
1867
+ outcome = JSON.parse(
1868
+ await nativeModule().exportRunProjectAsync(
1869
+ job.id,
1870
+ JSON.stringify(shards),
1871
+ JSON.stringify({
1872
+ version: VIDEO_SCENE_PROGRAM_VERSION,
1873
+ projectSize: { width: project.size.width, height: project.size.height },
1874
+ durationUs: program.durationUs,
1875
+ segments: program.segments
1876
+ }),
1877
+ JSON.stringify(resources),
1878
+ JSON.stringify(mixProgram),
1879
+ JSON.stringify(mixUris),
1880
+ /* timeoutSeconds= */
1881
+ 600,
1882
+ destination.kind === "file" ? JSON.stringify({
1883
+ kind: "file",
1884
+ path: destination.path,
1885
+ overwrite: destination.overwrite
1886
+ }) : ""
1887
+ )
1888
+ );
1889
+ } finally {
1890
+ clearInterval(ticker);
1891
+ options.releaseResources?.(staged);
1892
+ }
1893
+ const wedged = stalled.at;
1894
+ if (wedged) {
1895
+ pump.stop();
1896
+ throw new VideoExportStalled(job.id, wedged.stage, wedged.idleMs);
1897
+ }
1898
+ const cancelledDuring = /^cancelled during (\w+)/.exec(outcome.error ?? "");
1899
+ if (!outcome.ok && cancelledDuring) {
1900
+ pump.stop();
1901
+ throw new VideoExportCancelled2(job.id, cancelledDuring[1]);
1902
+ }
1903
+ if (!outcome.ok) {
1904
+ pump.stop();
1905
+ const error = outcome.error || "the export failed";
1906
+ if (isRefusal(error)) throw new VideoExportRejected([error]);
1907
+ throw new VideoExportFailed(job.id, "encoding", error);
1908
+ }
1909
+ job.checkpoint("publishing");
1910
+ const probe = JSON.parse(nativeModule().exportProbeOutput(outcome.uri));
1911
+ if (!probe.readable) {
1912
+ pump.stop();
1913
+ throw new VideoExportFailed(
1914
+ job.id,
1915
+ "publishing",
1916
+ `${describeTarget(outcome.uri)} could not be read back: ${probe.error ?? "no video track"}`
1917
+ );
1918
+ }
1919
+ pump.complete();
1920
+ pump.stop();
1921
+ if (destination.kind === "file") return { kind: "file", uri: outcome.uri };
1922
+ const temporaryUri = outcome.uri;
1923
+ return {
1924
+ kind: "temporary",
1925
+ uri: temporaryUri,
1926
+ release: async () => {
1927
+ await Promise.resolve();
1928
+ nativeModule().exportReleaseTemporaryOutput(temporaryUri);
1929
+ }
1930
+ };
1931
+ });
1932
+ },
1933
+ /**
1934
+ * Ask the running export to stop.
1935
+ *
1936
+ * Idempotent, and a no-op when nothing is running. Reaches the native job
1937
+ * before the promise settles: a Transformer nobody is listening to still
1938
+ * encodes, still writes and still holds its codecs, so rejecting in
1939
+ * JavaScript alone would show a cancelled state over a running export.
1940
+ */
1941
+ cancel() {
1942
+ jobs.cancel();
1943
+ }
1944
+ };
1945
+ }
1946
+ var REFUSAL_PATTERNS = [
1947
+ /^program rejected/,
1948
+ /^overlay resources? /,
1949
+ /^overlay resource /,
1950
+ /^no shards/,
1951
+ /already exists; pass overwrite/,
1952
+ /^the directory for /,
1953
+ /^the destination /,
1954
+ /cannot be written without overwrite/,
1955
+ /^the staged file could not be read back/
1956
+ ];
1957
+ function isRefusal(error) {
1958
+ return REFUSAL_PATTERNS.some((pattern) => pattern.test(error));
1959
+ }
1960
+ function cadenceFor(frames) {
1961
+ const first = frames[0].sourceFrameIndex;
1962
+ const last = frames[frames.length - 1].sourceFrameIndex;
1963
+ const emitsPerInput = [];
1964
+ for (let i = 0; i <= last - first; i += 1) emitsPerInput.push(0);
1965
+ const outputPtsUs = [];
1966
+ for (const frame of frames) {
1967
+ const slot = frame.sourceFrameIndex - first;
1968
+ emitsPerInput[slot] = (emitsPerInput[slot] ?? 0) + 1;
1969
+ outputPtsUs.push(frame.outputPtsUs);
1970
+ }
1971
+ return { emitsPerInput, outputPtsUs };
1972
+ }
1973
+
1974
+ // src/mediaStore.ts
1975
+ var MediaSaveFailed = class extends Error {
1976
+ constructor(reason) {
1977
+ super(`save failed: ${reason}`);
1978
+ this.name = "MediaSaveFailed";
1979
+ this.reason = reason;
1980
+ }
1981
+ };
1982
+ var MediaSaveCancelled = class extends Error {
1983
+ constructor() {
1984
+ super("save cancelled");
1985
+ this.name = "MediaSaveCancelled";
1986
+ }
1987
+ };
1988
+ function shareSavedMedia(uri) {
1989
+ if (!NativePictorVideo_default) {
1990
+ return { ok: false, noTargets: false, reason: "sharing is not available" };
1991
+ }
1992
+ if (!uri.startsWith("content://")) {
1993
+ return { ok: false, noTargets: false, reason: "only a saved video can be shared" };
1994
+ }
1995
+ const result = JSON.parse(NativePictorVideo_default.mediaShare(uri));
1996
+ return {
1997
+ ok: result.ok === true,
1998
+ noTargets: result.noTargets === true,
1999
+ reason: result.error ?? ""
2000
+ };
2001
+ }
2002
+ var counter2 = 0;
2003
+ function createMediaStoreSaver() {
2004
+ let active = null;
2005
+ const native = () => {
2006
+ if (!NativePictorVideo_default) {
2007
+ throw new MediaSaveFailed("the Pictor video native module is not installed");
2008
+ }
2009
+ return NativePictorVideo_default;
2010
+ };
2011
+ return {
2012
+ async save(sourceUri) {
2013
+ if (active !== null) {
2014
+ throw new MediaSaveFailed("a save is already running");
2015
+ }
2016
+ counter2 += 1;
2017
+ const id = `save_${counter2.toString(36)}`;
2018
+ active = id;
2019
+ let outcome;
2020
+ try {
2021
+ outcome = JSON.parse(await native().mediaSaveAsync(id, sourceUri));
2022
+ } finally {
2023
+ active = null;
2024
+ }
2025
+ if (outcome.cancelled === true) throw new MediaSaveCancelled();
2026
+ if (!outcome.ok || typeof outcome.uri !== "string") {
2027
+ throw new MediaSaveFailed(outcome.error ?? "the file could not be saved");
2028
+ }
2029
+ return {
2030
+ uri: outcome.uri,
2031
+ displayName: outcome.displayName ?? "",
2032
+ location: outcome.location ?? ""
2033
+ };
2034
+ },
2035
+ cancel() {
2036
+ if (active === null) return;
2037
+ native().mediaSaveCancel(active);
2038
+ }
2039
+ };
2040
+ }
2041
+ function sweepPendingMediaSaves() {
2042
+ if (!NativePictorVideo_default) return { swept: 0, inspected: 0, skipped: 0 };
2043
+ const result = JSON.parse(NativePictorVideo_default.mediaSaveSweepPending());
2044
+ return {
2045
+ swept: result.swept ?? 0,
2046
+ inspected: result.inspected ?? 0,
2047
+ skipped: result.skipped ?? 0
2048
+ };
2049
+ }
2050
+
2051
+ // src/overlayStaging.ts
2052
+ var installed = false;
2053
+ function installOverlayStaging() {
2054
+ if (installed && global.__pictorOverlayStaging !== void 0) return true;
2055
+ installed = requireNative().installOverlayStaging();
2056
+ return installed && global.__pictorOverlayStaging !== void 0;
2057
+ }
2058
+ function host() {
2059
+ const h = global.__pictorOverlayStaging;
2060
+ if (h === void 0) {
2061
+ throw new Error("@pictor-sdk/video-android: overlay staging is not installed");
2062
+ }
2063
+ return h;
2064
+ }
2065
+ function sweepOverlayStaging() {
2066
+ if (!installOverlayStaging()) return 0;
2067
+ return host().sweep();
2068
+ }
2069
+ function createOverlayStagingSink() {
2070
+ if (!installOverlayStaging()) {
2071
+ throw new Error("@pictor-sdk/video-android: overlay staging could not be installed");
2072
+ }
2073
+ const tokens = /* @__PURE__ */ new Map();
2074
+ return {
2075
+ stage(bytes, meta) {
2076
+ const exact = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength ? bytes.buffer : bytes.slice().buffer;
2077
+ const staged = host().stage(exact, meta.rasterWidth, meta.rasterHeight);
2078
+ tokens.set(staged.file, staged.token);
2079
+ return {
2080
+ file: staged.file,
2081
+ byteLength: staged.byteLength,
2082
+ checksum: staged.checksum
2083
+ };
2084
+ },
2085
+ remove(file) {
2086
+ const token = tokens.get(file);
2087
+ if (token === void 0) return;
2088
+ tokens.delete(file);
2089
+ host().release(token);
2090
+ },
2091
+ releaseAll() {
2092
+ tokens.clear();
2093
+ return host().releaseAll();
2094
+ },
2095
+ ledger() {
2096
+ return host().ledger();
2097
+ }
2098
+ };
2099
+ }
2100
+ function measureStagingStall(sink, width, height) {
2101
+ const bytes = new Uint8Array(width * height * 4);
2102
+ const at = Date.now();
2103
+ const staged = sink.stage(bytes, {
2104
+ overlayId: "measure",
2105
+ resourceId: "measure",
2106
+ rasterWidth: width,
2107
+ rasterHeight: height
2108
+ });
2109
+ const ms = Date.now() - at;
2110
+ sink.remove(staged.file);
2111
+ return { ms, bytes: bytes.byteLength };
2112
+ }
2113
+
2114
+ // src/audioProbe.ts
2115
+ function probeAudioSource(uri) {
2116
+ let raw;
2117
+ try {
2118
+ raw = requireNative().probeAudioSource(uri);
2119
+ } catch (error) {
2120
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2121
+ }
2122
+ let parsed;
2123
+ try {
2124
+ parsed = JSON.parse(raw);
2125
+ } catch {
2126
+ return { ok: false, reason: "probe returned unparseable JSON" };
2127
+ }
2128
+ const value = parsed;
2129
+ if (value.ok !== true) {
2130
+ return { ok: false, reason: typeof value.reason === "string" ? value.reason : "probe failed" };
2131
+ }
2132
+ return {
2133
+ ok: true,
2134
+ durationUs: Number.isFinite(value.durationUs) ? Number(value.durationUs) : 0,
2135
+ hasAudioTrack: value.hasAudioTrack === true,
2136
+ seekable: value.seekable === true,
2137
+ mimeType: typeof value.mimeType === "string" ? value.mimeType : ""
2138
+ };
2139
+ }
2140
+
2141
+ // src/managedSources.ts
2142
+ function ingestManagedSource(uri) {
2143
+ return parse(
2144
+ () => requireNative().ingestManagedSource(uri),
2145
+ (value) => ({
2146
+ ok: true,
2147
+ uri: String(value.uri ?? ""),
2148
+ token: String(value.token ?? ""),
2149
+ byteLength: Number(value.byteLength ?? 0),
2150
+ sha256: String(value.sha256 ?? ""),
2151
+ created: value.created === true
2152
+ })
2153
+ );
2154
+ }
2155
+ function releaseManagedSource(token) {
2156
+ try {
2157
+ const value = JSON.parse(requireNative().releaseManagedSource(token));
2158
+ return value.removed === true;
2159
+ } catch {
2160
+ return false;
2161
+ }
2162
+ }
2163
+ function sweepManagedSources(keepTokens) {
2164
+ try {
2165
+ const value = JSON.parse(
2166
+ requireNative().sweepManagedSources(JSON.stringify([...keepTokens]))
2167
+ );
2168
+ if (value.ok !== true) return { removed: 0, kept: 0, partials: 0, skipped: 0 };
2169
+ return {
2170
+ removed: Number(value.removed ?? 0),
2171
+ kept: Number(value.kept ?? 0),
2172
+ partials: Number(value.partials ?? 0),
2173
+ skipped: Number(value.skipped ?? 0)
2174
+ };
2175
+ } catch {
2176
+ return { removed: 0, kept: 0, partials: 0, skipped: 0 };
2177
+ }
2178
+ }
2179
+ function managedSourceList() {
2180
+ try {
2181
+ const value = JSON.parse(requireNative().managedSourceList());
2182
+ return value.items ?? [];
2183
+ } catch {
2184
+ return [];
2185
+ }
2186
+ }
2187
+ function seedManagedSourceForGate(name, bytes) {
2188
+ try {
2189
+ const value = JSON.parse(requireNative().managedSourceGateSeed(name, bytes));
2190
+ return value.ok === true;
2191
+ } catch {
2192
+ return false;
2193
+ }
2194
+ }
2195
+ function sweepManagedSourcesRawForGate(rawKeep) {
2196
+ try {
2197
+ const value = JSON.parse(requireNative().managedSourceGateSweepRaw(rawKeep));
2198
+ return {
2199
+ ok: value.ok === true,
2200
+ removed: Number(value.removed ?? 0),
2201
+ kept: Number(value.kept ?? 0),
2202
+ partials: Number(value.partials ?? 0),
2203
+ skipped: Number(value.skipped ?? 0)
2204
+ };
2205
+ } catch {
2206
+ return { ok: false, removed: 0, kept: 0, partials: 0, skipped: 0 };
2207
+ }
2208
+ }
2209
+ function managedSourceLedger() {
2210
+ try {
2211
+ const value = JSON.parse(requireNative().managedSourceLedger());
2212
+ return {
2213
+ count: Number(value.count ?? 0),
2214
+ bytes: Number(value.bytes ?? 0),
2215
+ items: value.items ?? []
2216
+ };
2217
+ } catch {
2218
+ return { count: 0, bytes: 0, items: [] };
2219
+ }
2220
+ }
2221
+ function managedSourceToken(uri) {
2222
+ const match = /\/([0-9a-f]{64})\.bin$/.exec(uri);
2223
+ return match ? match[1] : null;
2224
+ }
2225
+ function parse(call2, build) {
2226
+ let raw;
2227
+ try {
2228
+ raw = call2();
2229
+ } catch (error) {
2230
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2231
+ }
2232
+ let value;
2233
+ try {
2234
+ value = JSON.parse(raw);
2235
+ } catch {
2236
+ return { ok: false, reason: "native returned unparseable JSON" };
2237
+ }
2238
+ if (value.ok !== true) {
2239
+ return { ok: false, reason: typeof value.reason === "string" ? value.reason : "failed" };
2240
+ }
2241
+ return build(value);
2242
+ }
2243
+
2244
+ // src/previewMix.ts
2245
+ async function preparePreviewMix(jobId, programJson, urisJson) {
2246
+ let raw;
2247
+ try {
2248
+ raw = await requireNative().previewMixPrepare(jobId, programJson, urisJson);
2249
+ } catch (error) {
2250
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2251
+ }
2252
+ let value;
2253
+ try {
2254
+ value = JSON.parse(raw);
2255
+ } catch {
2256
+ return { ok: false, reason: "native returned unparseable JSON" };
2257
+ }
2258
+ if (value.ok === true) {
2259
+ return {
2260
+ ok: true,
2261
+ uri: String(value.uri ?? ""),
2262
+ key: String(value.key ?? ""),
2263
+ cached: value.cached === true,
2264
+ byteLength: Number(value.byteLength ?? 0),
2265
+ ...value.profile ? { profile: value.profile } : {}
2266
+ };
2267
+ }
2268
+ if (value.superseded === true) {
2269
+ return { ok: false, superseded: true, key: String(value.key ?? "") };
2270
+ }
2271
+ return {
2272
+ ok: false,
2273
+ ...typeof value.key === "string" ? { key: value.key } : {},
2274
+ reason: typeof value.error === "string" ? value.error : "the mix could not be prepared"
2275
+ };
2276
+ }
2277
+ function cancelPreviewMix() {
2278
+ try {
2279
+ requireNative().previewMixCancel();
2280
+ } catch {
2281
+ }
2282
+ }
2283
+ function previewMixKey(programJson, urisJson) {
2284
+ try {
2285
+ return requireNative().previewMixKey(programJson, urisJson);
2286
+ } catch {
2287
+ return "";
2288
+ }
2289
+ }
2290
+ function releasePreviewMix(key) {
2291
+ try {
2292
+ const value = JSON.parse(requireNative().previewMixRelease(key));
2293
+ return value.removed === true;
2294
+ } catch {
2295
+ return false;
2296
+ }
2297
+ }
2298
+ function sweepPreviewMixes(keepKeys) {
2299
+ const none = { removed: 0, kept: 0, partials: 0, skipped: 0 };
2300
+ try {
2301
+ const value = JSON.parse(requireNative().previewMixSweep(JSON.stringify([...keepKeys])));
2302
+ if (value.ok !== true) return none;
2303
+ return {
2304
+ removed: Number(value.removed ?? 0),
2305
+ kept: Number(value.kept ?? 0),
2306
+ partials: Number(value.partials ?? 0),
2307
+ skipped: Number(value.skipped ?? 0)
2308
+ };
2309
+ } catch {
2310
+ return none;
2311
+ }
2312
+ }
2313
+ function previewMixLedger() {
2314
+ try {
2315
+ const value = JSON.parse(requireNative().previewMixLedger());
2316
+ return {
2317
+ count: Number(value.count ?? 0),
2318
+ bytes: Number(value.bytes ?? 0),
2319
+ items: value.items ?? []
2320
+ };
2321
+ } catch {
2322
+ return { count: 0, bytes: 0, items: [] };
2323
+ }
2324
+ }
2325
+
2326
+ // src/previewAudioSession.ts
2327
+ import { compileAudioMixProgram as compileAudioMixProgram2 } from "@pictor-sdk/video-core";
2328
+ function previewAudioNeeded(project) {
2329
+ return project.audio.length > 0;
2330
+ }
2331
+ function decidePreviewAudio(inputs) {
2332
+ const { wantedKey, readyKey, preparingKey, failedKey } = inputs;
2333
+ if (wantedKey === "") {
2334
+ return {
2335
+ status: "source",
2336
+ wantedKey,
2337
+ shouldPrepare: false,
2338
+ muteSourceAudio: false,
2339
+ canPlay: true
2340
+ };
2341
+ }
2342
+ if (readyKey === wantedKey) {
2343
+ return {
2344
+ status: "ready",
2345
+ wantedKey,
2346
+ shouldPrepare: false,
2347
+ // The mix contains the clips' audio too. Leaving the source audible
2348
+ // would play the whole timeline twice.
2349
+ muteSourceAudio: true,
2350
+ canPlay: true
2351
+ };
2352
+ }
2353
+ if (failedKey === wantedKey) {
2354
+ return {
2355
+ status: "failed",
2356
+ wantedKey,
2357
+ shouldPrepare: false,
2358
+ muteSourceAudio: true,
2359
+ canPlay: false
2360
+ };
2361
+ }
2362
+ return {
2363
+ status: "preparing",
2364
+ wantedKey,
2365
+ shouldPrepare: preparingKey !== wantedKey,
2366
+ muteSourceAudio: true,
2367
+ canPlay: false
2368
+ };
2369
+ }
2370
+ function previewProgramJson(project) {
2371
+ return JSON.stringify(compileAudioMixProgram2(project));
2372
+ }
2373
+ var PREVIEW_DRIFT_TOLERANCE_US = 6e4;
2374
+
2375
+ // src/tracePhases.ts
2376
+ var PHASE_MARK_PREFIX = "phase:";
2377
+ function num(v) {
2378
+ return typeof v === "number" ? v : Number(v);
2379
+ }
2380
+ function empty(label, index) {
2381
+ return {
2382
+ label,
2383
+ index,
2384
+ samples: 0,
2385
+ playingSamples: 0,
2386
+ freshPlayingSamples: 0,
2387
+ stalePlayingSamples: 0,
2388
+ longestStaleRun: 0,
2389
+ minUs: 0,
2390
+ maxUs: 0,
2391
+ meanUs: 0,
2392
+ worstAbsUs: 0,
2393
+ freshWorstAbsUs: 0,
2394
+ overToleranceSamples: 0,
2395
+ settledAfterUs: 0
2396
+ };
2397
+ }
2398
+ function summarisePhases(events, toleranceUs) {
2399
+ const phases = [];
2400
+ let current = empty("start", 0);
2401
+ let sum = 0;
2402
+ let staleRun = 0;
2403
+ let firstPlayingNs = 0;
2404
+ let lastOverNs = 0;
2405
+ let sawPlaying = false;
2406
+ let stillOver = false;
2407
+ const close = () => {
2408
+ current.meanUs = current.playingSamples > 0 ? Math.round(sum / current.playingSamples) : 0;
2409
+ current.settledAfterUs = current.overToleranceSamples === 0 ? 0 : stillOver ? -1 : Math.round((lastOverNs - firstPlayingNs) / 1e3);
2410
+ phases.push(current);
2411
+ sum = 0;
2412
+ staleRun = 0;
2413
+ firstPlayingNs = 0;
2414
+ lastOverNs = 0;
2415
+ sawPlaying = false;
2416
+ stillOver = false;
2417
+ };
2418
+ for (const e of events) {
2419
+ if (e.type !== "sample") {
2420
+ const label = e.type === "mark" ? String(e.v.label ?? "") : "";
2421
+ if (label.startsWith(PHASE_MARK_PREFIX)) {
2422
+ close();
2423
+ current = empty(label.slice(PHASE_MARK_PREFIX.length), phases.length);
2424
+ }
2425
+ continue;
2426
+ }
2427
+ current.samples += 1;
2428
+ if (e.v.videoPlaying !== true || e.v.audioPlaying !== true) {
2429
+ staleRun = 0;
2430
+ continue;
2431
+ }
2432
+ const offset = num(e.v.offsetUs);
2433
+ if (!Number.isFinite(offset)) continue;
2434
+ current.playingSamples += 1;
2435
+ if (!sawPlaying) {
2436
+ sawPlaying = true;
2437
+ firstPlayingNs = num(e.atNs);
2438
+ }
2439
+ sum += offset;
2440
+ if (current.playingSamples === 1) {
2441
+ current.minUs = offset;
2442
+ current.maxUs = offset;
2443
+ } else {
2444
+ current.minUs = Math.min(current.minUs, offset);
2445
+ current.maxUs = Math.max(current.maxUs, offset);
2446
+ }
2447
+ const abs = Math.abs(offset);
2448
+ if (abs > current.worstAbsUs) current.worstAbsUs = abs;
2449
+ if (abs > toleranceUs) {
2450
+ current.overToleranceSamples += 1;
2451
+ lastOverNs = num(e.atNs);
2452
+ stillOver = true;
2453
+ } else {
2454
+ stillOver = false;
2455
+ }
2456
+ if (e.v.pfFresh === true) {
2457
+ current.freshPlayingSamples += 1;
2458
+ if (abs > current.freshWorstAbsUs) current.freshWorstAbsUs = abs;
2459
+ staleRun = 0;
2460
+ } else {
2461
+ current.stalePlayingSamples += 1;
2462
+ staleRun += 1;
2463
+ if (staleRun > current.longestStaleRun) current.longestStaleRun = staleRun;
2464
+ }
2465
+ }
2466
+ close();
2467
+ const worstAbsUs = phases.reduce((w, p) => Math.max(w, p.worstAbsUs), 0);
2468
+ const freshWorstAbsUs = phases.reduce((w, p) => Math.max(w, p.freshWorstAbsUs), 0);
2469
+ const overToleranceSamples = phases.reduce((n, p) => n + p.overToleranceSamples, 0);
2470
+ return {
2471
+ toleranceUs,
2472
+ phases,
2473
+ worstAbsUs,
2474
+ freshWorstAbsUs,
2475
+ overToleranceSamples,
2476
+ pass: overToleranceSamples === 0
2477
+ };
2478
+ }
2479
+
2480
+ // src/spikeExcursions.ts
2481
+ var SPIKE_TOLERANCE_US = 6e4;
2482
+ function offsetAt(sample, releaseAtNs = 0) {
2483
+ if (releaseAtNs > 0 && sample.frameConsumedNs < releaseAtNs) {
2484
+ const raw = sample.positionUs - sample.framePtsUs;
2485
+ return { atLatch: raw, atIntended: raw };
2486
+ }
2487
+ return {
2488
+ atLatch: sample.positionUs - (sample.atNs - sample.frameConsumedNs) / 1e3 - sample.framePtsUs,
2489
+ atIntended: sample.positionUs - (sample.atNs - sample.frameReleaseNs) / 1e3 - sample.framePtsUs
2490
+ };
2491
+ }
2492
+ function classifyExcursions(samples, options = {}) {
2493
+ const toleranceUs = options.toleranceUs ?? SPIKE_TOLERANCE_US;
2494
+ const frameIntervalUs = options.frameIntervalUs ?? 33333;
2495
+ const ADVANCE_WINDOW_NS = 2e8;
2496
+ const advancing = samples.map((s, i) => {
2497
+ for (let j = i - 1; j >= 0; j -= 1) {
2498
+ if (s.atNs - samples[j].atNs > ADVANCE_WINDOW_NS) break;
2499
+ if (samples[j].positionUs !== s.positionUs) return true;
2500
+ }
2501
+ return false;
2502
+ });
2503
+ const releaseAtNs = [];
2504
+ let intervalStart = 0;
2505
+ for (let i = 0; i < samples.length; i += 1) {
2506
+ if (!advancing[i]) intervalStart = 0;
2507
+ else if (intervalStart === 0) intervalStart = samples[i].atNs;
2508
+ releaseAtNs.push(intervalStart);
2509
+ }
2510
+ const isActive = (s, i) => s.playing && s.ready && s.framePtsUs >= 0 && s.frameConsumedNs > 0 && // The picture for this epoch has arrived.
2511
+ s.frameConsumedNs >= s.atNs - s.sinceEpochNs && advancing[i] === true;
2512
+ const active = samples.filter(isActive);
2513
+ const preparing = samples.length - active.length;
2514
+ const excursions = [];
2515
+ const byCause = {};
2516
+ let worstAbsUs = 0;
2517
+ let worstAtIntendedUs = 0;
2518
+ let overAtIntended = 0;
2519
+ let readSpanUs = 0;
2520
+ let latchLagSum = 0;
2521
+ let preReleaseSamples = 0;
2522
+ let preReleaseWorstUs = 0;
2523
+ const releaseFor = /* @__PURE__ */ new Map();
2524
+ samples.forEach((s, i) => releaseFor.set(s.seq, releaseAtNs[i]));
2525
+ for (let i = 0; i < active.length; i += 1) {
2526
+ const s = active[i];
2527
+ const previous = i > 0 ? active[i - 1] : null;
2528
+ readSpanUs = Math.max(readSpanUs, s.readSpanNs / 1e3);
2529
+ const releasedAt = releaseFor.get(s.seq) ?? 0;
2530
+ const both = offsetAt(s, releasedAt);
2531
+ const offsetUs = both.atLatch;
2532
+ const abs = Math.abs(offsetUs);
2533
+ const absIntended = Math.abs(both.atIntended);
2534
+ if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
2535
+ preReleaseSamples += 1;
2536
+ if (abs > preReleaseWorstUs) preReleaseWorstUs = abs;
2537
+ }
2538
+ if (abs > worstAbsUs) worstAbsUs = abs;
2539
+ if (absIntended > worstAtIntendedUs) worstAtIntendedUs = absIntended;
2540
+ if (absIntended > toleranceUs) overAtIntended += 1;
2541
+ if (abs <= toleranceUs) continue;
2542
+ const frameAgeUs = (s.atNs - s.frameConsumedNs) / 1e3;
2543
+ const latchLagUs = (s.frameConsumedNs - s.frameReleaseNs) / 1e3;
2544
+ latchLagSum += latchLagUs;
2545
+ const positionStepUs = previous ? s.positionUs - previous.positionUs : 0;
2546
+ const wallStepUs = previous ? (s.atNs - previous.atNs) / 1e3 : 0;
2547
+ let cause;
2548
+ if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
2549
+ cause = "pre-release-frame";
2550
+ } else if (s.frameConsumedNs > 0 && s.frameConsumedNs < s.atNs - s.sinceEpochNs) {
2551
+ cause = "cross-epoch";
2552
+ } else if (previous && s.frameGeneration !== previous.frameGeneration) {
2553
+ cause = "cross-epoch";
2554
+ } else if (frameAgeUs > 2 * frameIntervalUs) {
2555
+ cause = "stale-pairing";
2556
+ } else if (s.sinceEpochNs / 1e3 < 5e5) {
2557
+ cause = "first-frame-after-seek";
2558
+ } else if (latchLagUs > frameIntervalUs) {
2559
+ cause = "delayed-presentation";
2560
+ } else if (previous && Math.abs(positionStepUs - wallStepUs) > toleranceUs) {
2561
+ cause = "audio-discontinuity";
2562
+ } else if (previous && s.frameWindow !== previous.frameWindow) {
2563
+ cause = "mapping";
2564
+ } else {
2565
+ cause = "unexplained";
2566
+ }
2567
+ byCause[cause] = (byCause[cause] ?? 0) + 1;
2568
+ excursions.push({
2569
+ seq: s.seq,
2570
+ offsetUs: Math.round(offsetUs),
2571
+ offsetAtIntendedUs: Math.round(both.atIntended),
2572
+ cause,
2573
+ epoch: s.epoch,
2574
+ sinceEpochMs: Math.round(s.sinceEpochNs / 1e6),
2575
+ frameWindow: s.frameWindow,
2576
+ framePtsUs: s.framePtsUs,
2577
+ latchLagUs: Math.round(latchLagUs),
2578
+ frameAgeUs: Math.round(frameAgeUs),
2579
+ positionStepUs: Math.round(positionStepUs),
2580
+ wallStepUs: Math.round(wallStepUs)
2581
+ });
2582
+ }
2583
+ return {
2584
+ toleranceUs,
2585
+ activeSamples: active.length,
2586
+ preparingSamples: preparing,
2587
+ excursions,
2588
+ byCause,
2589
+ preReleaseSamples,
2590
+ preReleaseWorstUs: Math.round(preReleaseWorstUs),
2591
+ worstAbsUs: Math.round(worstAbsUs),
2592
+ worstAtIntendedUs: Math.round(worstAtIntendedUs),
2593
+ overAtIntended,
2594
+ passAtIntended: active.length > 0 && overAtIntended === 0,
2595
+ meanLatchLagUs: excursions.length ? Math.round(latchLagSum / excursions.length) : 0,
2596
+ // No samples is not a pass. A run that never played has not been judged.
2597
+ pass: active.length > 0 && excursions.length === 0,
2598
+ uncertainty: {
2599
+ frameQuantumUs: Math.round(frameIntervalUs / 2),
2600
+ readSpanUs: Math.round(readSpanUs),
2601
+ positionQuantumUs: 1e3
2602
+ }
2603
+ };
2604
+ }
2605
+ function windowAround(samples, seq, spanUs = 25e4) {
2606
+ const centre = samples.find((s) => s.seq === seq);
2607
+ if (!centre) return [];
2608
+ const spanNs = spanUs * 1e3;
2609
+ return samples.filter((s) => Math.abs(s.atNs - centre.atNs) <= spanNs);
2610
+ }
2611
+
2612
+ // src/previewCadence.ts
2613
+ import { resampleClip } from "@pictor-sdk/video-core";
2614
+ var PREVIEW_TOLERANCE_US = 6e4;
2615
+ var PARITY_LEVEL_TOLERANCE = 12;
2616
+ var PARITY_BAR_TOLERANCE = 4 / 960;
2617
+ var PREVIEW_OUTPUT_FPS = 30;
2618
+ function wallClockAt(samples, projectUs, intervalUs) {
2619
+ let before = null;
2620
+ let after = null;
2621
+ for (const s of samples) {
2622
+ if (!s.playing || s.positionUs < 0 || s.atNs <= 0) continue;
2623
+ if (s.positionUs <= projectUs && (before === null || s.positionUs > before.positionUs)) before = s;
2624
+ if (s.positionUs >= projectUs && (after === null || s.positionUs < after.positionUs)) after = s;
2625
+ }
2626
+ if (after === null) {
2627
+ if (before === null || projectUs - before.positionUs > intervalUs) return null;
2628
+ return before.atNs + (projectUs - before.positionUs) * 1e3;
2629
+ }
2630
+ if (before === null) {
2631
+ if (after.positionUs - projectUs > intervalUs) return null;
2632
+ return after.atNs - (after.positionUs - projectUs) * 1e3;
2633
+ }
2634
+ if (before.epoch !== after.epoch) return null;
2635
+ if (after.positionUs === before.positionUs) return before.atNs;
2636
+ const t = (projectUs - before.positionUs) / (after.positionUs - before.positionUs);
2637
+ return before.atNs + t * (after.atNs - before.atNs);
2638
+ }
2639
+ function displayedAt(submissions, projectUs, atNs, intervalNs) {
2640
+ if (atNs === null) return null;
2641
+ const deadlineNs = atNs + intervalNs;
2642
+ let best = null;
2643
+ for (const s of submissions) {
2644
+ if (s.ptsUs > projectUs) continue;
2645
+ if (s.submittedNs > deadlineNs) continue;
2646
+ if (best === null || s.submittedNs > best.submittedNs) best = s;
2647
+ }
2648
+ return best;
2649
+ }
2650
+ function checkCadence(clip, submissions, outputFps = PREVIEW_OUTPUT_FPS, toleranceUs = PREVIEW_TOLERANCE_US, samples = []) {
2651
+ const request = {
2652
+ sourceStartUs: clip.sourceStartUs,
2653
+ sourceEndUs: clip.sourceEndUs,
2654
+ speed: clip.speed,
2655
+ sourceFps: clip.sourceFps,
2656
+ targetFps: outputFps
2657
+ };
2658
+ const plan = resampleClip(request);
2659
+ const outputIntervalUs = Math.round(1e6 / outputFps);
2660
+ const inClip = submissions.filter((s) => s.ptsUs >= clip.timelineStartUs && s.ptsUs < clip.timelineEndUs).sort((a, b) => a.ptsUs - b.ptsUs);
2661
+ const skews = inClip.filter((s) => s.texturePtsUs >= 0).map((s) => Math.abs(s.texturePtsUs - s.ptsUs)).sort((a, b) => a - b);
2662
+ const medianSkewUs = skews.length > 0 ? skews[Math.floor(skews.length / 2)] : -1;
2663
+ const timestampDomain = skews.length === 0 ? "unknown" : medianSkewUs < 1e6 ? "media" : "system";
2664
+ const unique = new Set(inClip.map((s) => s.ptsUs));
2665
+ const delivered = /* @__PURE__ */ new Set();
2666
+ let markersRead = 0;
2667
+ let markersFailed = 0;
2668
+ for (const record2 of inClip) {
2669
+ if (record2.acquiredMarker >= 0) delivered.add(record2.acquiredMarker);
2670
+ if (record2.submittedMarker >= 0) markersRead += 1;
2671
+ else if (record2.markerFailure !== "" && record2.markerFailure !== "unread") markersFailed += 1;
2672
+ }
2673
+ const pixelIdentity = markersRead > 0;
2674
+ const first = inClip.length > 0 ? inClip[0].ptsUs : 0;
2675
+ const last = inClip.length > 0 ? inClip[inClip.length - 1].ptsUs : 0;
2676
+ const clockOffsets = inClip.filter((r) => r.submittedNs > 0).map((r) => r.submittedNs - r.ptsUs * 1e3).sort((a, b) => a - b);
2677
+ const playerOffsets = samples.filter((s) => s.playing && s.atNs > 0 && s.positionUs >= 0).map((s) => s.atNs - s.positionUs * 1e3).sort((a, b) => a - b);
2678
+ const frameDerivedOffsetNs = clockOffsets.length > 0 && playerOffsets.length > 0 ? clockOffsets[Math.floor(clockOffsets.length / 2)] - playerOffsets[Math.floor(playerOffsets.length / 2)] : 0;
2679
+ let expectedInstants = 0;
2680
+ let leadingExcluded = 0;
2681
+ let trailingExcluded = 0;
2682
+ let covered = 0;
2683
+ let wrong = 0;
2684
+ let unverified = 0;
2685
+ let unplaceable = 0;
2686
+ let exact = 0;
2687
+ const offsets = {};
2688
+ const sourceIntervalUs = clip.sourceFps > 0 ? 1e6 / clip.sourceFps / clip.speed : 0;
2689
+ const wrongInstants = [];
2690
+ for (const frame of plan.frames) {
2691
+ const projectUs = clip.timelineStartUs + Number(frame.outputPtsUs);
2692
+ if (inClip.length === 0) continue;
2693
+ const boundary = projectUs < first || projectUs > last;
2694
+ if (projectUs < first) leadingExcluded += 1;
2695
+ else if (projectUs > last) trailingExcluded += 1;
2696
+ else expectedInstants += 1;
2697
+ const instantNs = wallClockAt(samples, projectUs, outputIntervalUs);
2698
+ if (instantNs === null && !boundary) unplaceable += 1;
2699
+ const shown = instantNs === null ? null : displayedAt(inClip, projectUs, instantNs, outputIntervalUs * 1e3);
2700
+ if (shown === null) {
2701
+ if (boundary) {
2702
+ wrongInstants.push({
2703
+ projectUs,
2704
+ expectedSourceFrame: frame.sourceFrameIndex,
2705
+ expectedSourcePtsUs: Number(frame.sourceTimeUs),
2706
+ shownSourceFrame: -1,
2707
+ shownPtsUs: -1,
2708
+ acquiredTexturePtsUs: -1,
2709
+ acquiredMarker: -1,
2710
+ submittedMarker: -1,
2711
+ slot: -1,
2712
+ frameId: -1,
2713
+ submittedNs: 0,
2714
+ cause: "uncovered",
2715
+ boundary
2716
+ });
2717
+ }
2718
+ continue;
2719
+ }
2720
+ if (!boundary) covered += 1;
2721
+ const shownProjectUs = timestampDomain === "media" ? shown.texturePtsUs : shown.ptsUs;
2722
+ const shownSourceUs = clip.sourceStartUs + (shownProjectUs - clip.timelineStartUs) * clip.speed;
2723
+ const derivedIndex = Math.floor(shownSourceUs * clip.sourceFps / 1e6);
2724
+ const shownIndex = shown.submittedMarker >= 0 ? shown.submittedMarker : derivedIndex;
2725
+ const verified = shown.submittedMarker >= 0 || timestampDomain === "media";
2726
+ if (!verified) {
2727
+ if (!boundary) unverified += 1;
2728
+ continue;
2729
+ }
2730
+ const offset = shownIndex - frame.sourceFrameIndex;
2731
+ if (!boundary) {
2732
+ const key = String(offset);
2733
+ offsets[key] = (offsets[key] ?? 0) + 1;
2734
+ }
2735
+ const offsetUs = Math.abs(offset) * sourceIntervalUs;
2736
+ const slack = shown.submittedMarker >= 0 ? 0 : sourceIntervalUs;
2737
+ if (offset === 0) exact += 1;
2738
+ if (offsetUs <= Math.max(toleranceUs, slack)) continue;
2739
+ if (!boundary) wrong += 1;
2740
+ const acquired = shown.texturePtsUs;
2741
+ const late = shown.submittedNs > 0 && shown.ptsUs < projectUs - outputIntervalUs;
2742
+ const hadItByThen = inClip.some(
2743
+ (r) => r.acquiredMarker === frame.sourceFrameIndex && r.acquiredNs > 0 && r.acquiredNs <= shown.submittedNs
2744
+ );
2745
+ let cause;
2746
+ if (shown.submittedMarker >= 0) {
2747
+ if (shown.acquiredMarker >= 0 && shown.submittedMarker !== shown.acquiredMarker) {
2748
+ cause = "storage-aliased";
2749
+ } else if (!hadItByThen) cause = "not-delivered";
2750
+ else cause = "wrong-selection";
2751
+ } else if (late) cause = "late-delivery";
2752
+ else cause = "wrong-submitted-identity";
2753
+ wrongInstants.push({
2754
+ projectUs,
2755
+ expectedSourceFrame: frame.sourceFrameIndex,
2756
+ expectedSourcePtsUs: Number(frame.sourceTimeUs),
2757
+ shownSourceFrame: shownIndex,
2758
+ shownPtsUs: shownProjectUs,
2759
+ acquiredTexturePtsUs: acquired,
2760
+ acquiredMarker: shown.acquiredMarker,
2761
+ submittedMarker: shown.submittedMarker,
2762
+ slot: shown.slot,
2763
+ frameId: shown.frameId,
2764
+ submittedNs: shown.submittedNs,
2765
+ cause,
2766
+ boundary
2767
+ });
2768
+ }
2769
+ const walls = [];
2770
+ for (let i = 1; i < inClip.length; i += 1) {
2771
+ const gap = (inClip[i].submittedNs - inClip[i - 1].submittedNs) / 1e3;
2772
+ if (gap > 0) walls.push(gap);
2773
+ }
2774
+ walls.sort((a, b) => a - b);
2775
+ const tail = (fraction) => walls.length === 0 ? 0 : Math.round(walls[Math.min(walls.length - 1, Math.floor(walls.length * fraction))]);
2776
+ const frameTimes = {
2777
+ p50: tail(0.5),
2778
+ p95: tail(0.95),
2779
+ p99: tail(0.99),
2780
+ max: walls.length > 0 ? Math.round(walls[walls.length - 1]) : 0,
2781
+ samples: walls.length
2782
+ };
2783
+ let parityMeasured = 0;
2784
+ let worstLevelDelta = 0;
2785
+ let worstBarDelta = 0;
2786
+ let parityFailures = 0;
2787
+ for (const record2 of inClip) {
2788
+ if (record2.levelDelta < 0 || record2.barDelta < 0) continue;
2789
+ parityMeasured += 1;
2790
+ if (record2.levelDelta > worstLevelDelta) worstLevelDelta = record2.levelDelta;
2791
+ if (record2.barDelta > worstBarDelta) worstBarDelta = record2.barDelta;
2792
+ if (record2.levelDelta > PARITY_LEVEL_TOLERANCE || record2.barDelta > PARITY_BAR_TOLERANCE) {
2793
+ parityFailures += 1;
2794
+ }
2795
+ }
2796
+ const stamps = [...unique].sort((a, b) => a - b);
2797
+ const holds = [];
2798
+ for (let i = 1; i < stamps.length; i += 1) holds.push(stamps[i] - stamps[i - 1]);
2799
+ const repeats = Math.max(1, Math.round(1 / clip.speed));
2800
+ const expectedHoldUs = outputIntervalUs * repeats;
2801
+ const missedDeadlines = holds.filter((h) => h > expectedHoldUs * 1.5).length;
2802
+ holds.sort((a, b) => a - b);
2803
+ let verdict = "pass";
2804
+ let verdictReason = "";
2805
+ if (expectedInstants === 0) {
2806
+ verdict = "invalid";
2807
+ verdictReason = "no instants covered";
2808
+ } else if (!pixelIdentity) {
2809
+ verdict = "invalid";
2810
+ verdictReason = "identity not read from pixels";
2811
+ } else if (parityMeasured === 0) {
2812
+ verdict = "invalid";
2813
+ verdictReason = "rendering parity not measured";
2814
+ } else if (unplaceable > 0) {
2815
+ verdict = "invalid";
2816
+ verdictReason = `${unplaceable} instants could not be placed on the clock`;
2817
+ } else if (unverified > 0) {
2818
+ verdict = "invalid";
2819
+ verdictReason = `${unverified} instants unverified`;
2820
+ } else if (wrong > 0) {
2821
+ verdict = "fail";
2822
+ verdictReason = `${wrong} wrong instants`;
2823
+ } else if (covered !== expectedInstants) {
2824
+ verdict = "fail";
2825
+ verdictReason = `${expectedInstants - covered} instants uncovered`;
2826
+ } else if (missedDeadlines > 0) {
2827
+ verdict = "fail";
2828
+ verdictReason = `${missedDeadlines} missed deadlines`;
2829
+ } else if (parityFailures > 0) {
2830
+ verdict = "fail";
2831
+ verdictReason = `${parityFailures} parity failures`;
2832
+ }
2833
+ return {
2834
+ clip: clip.name,
2835
+ nominalInstants: plan.frames.length,
2836
+ leadingExcluded,
2837
+ trailingExcluded,
2838
+ expectedInstants,
2839
+ coveredInstants: covered,
2840
+ wrongFrameInstants: wrong,
2841
+ uncoveredInstants: expectedInstants - covered,
2842
+ submittedUnique: unique.size,
2843
+ ruleSkippedSourceFrames: plan.droppedSourceFrameCount,
2844
+ outputIntervalUs,
2845
+ medianHoldUs: holds.length > 0 ? holds[Math.floor(holds.length / 2)] : 0,
2846
+ maxHoldUs: holds.length > 0 ? holds[holds.length - 1] : 0,
2847
+ expectedHoldUs,
2848
+ missedDeadlines,
2849
+ parity: {
2850
+ measured: parityMeasured,
2851
+ worstLevelDelta,
2852
+ worstBarDelta: Number(worstBarDelta.toFixed(5)),
2853
+ failures: parityFailures,
2854
+ // No measurement is not a pass: a run that never looked has not looked.
2855
+ pass: parityMeasured > 0 && parityFailures === 0
2856
+ },
2857
+ offsets,
2858
+ unplaceableInstants: unplaceable,
2859
+ frameDerivedOffsetUs: Math.round(frameDerivedOffsetNs / 1e3),
2860
+ exactInstants: exact,
2861
+ worstOffsetUs: Math.round(
2862
+ Object.keys(offsets).reduce((worst, k) => Math.max(worst, Math.abs(Number(k))), 0) * (clip.sourceFps > 0 ? 1e6 / clip.sourceFps / clip.speed : 0)
2863
+ ),
2864
+ identity: {
2865
+ timestampDomain,
2866
+ medianTextureSkewUs: medianSkewUs,
2867
+ pixelIdentity,
2868
+ identityUnverified: unverified,
2869
+ deliveredFrames: delivered.size,
2870
+ markersRead,
2871
+ markersFailed
2872
+ },
2873
+ frameTimeUs: frameTimes,
2874
+ wrong: wrongInstants,
2875
+ // Every expected instant covered, by the frame the shared rule names, and
2876
+ // no picture held past its turn.
2877
+ /*
2878
+ An unverified instant is not a passing one.
2879
+
2880
+ With the probe off and the timestamps in the system domain there is no
2881
+ evidence about which frame was shown, and a check that reported that
2882
+ absence as success would be the same mistake as the one the band was
2883
+ built to correct.
2884
+ */
2885
+ /*
2886
+ The preview contract: temporally accurate, correctly rendered, covered.
2887
+
2888
+ Exact export-frame identity is deliberately absent. `exactInstants` and
2889
+ `offsets` record it, and `@see PREVIEW_TOLERANCE_US` for why requiring it
2890
+ here would be requiring a per-frame seek.
2891
+ */
2892
+ pass: verdict === "pass",
2893
+ verdict,
2894
+ verdictReason
2895
+ };
2896
+ }
2897
+
2898
+ // src/submissionScoring.ts
2899
+ function audioAt(samples, atNs, windowNs) {
2900
+ let before = null;
2901
+ let after = null;
2902
+ for (const s of samples) {
2903
+ if (!s.playing) continue;
2904
+ if (s.atNs <= atNs && (before === null || s.atNs > before.atNs)) before = s;
2905
+ if (s.atNs >= atNs && (after === null || s.atNs < after.atNs)) after = s;
2906
+ }
2907
+ if (before !== null && after !== null && after.atNs > before.atNs) {
2908
+ const spanUs = (after.atNs - before.atNs) / 1e3;
2909
+ const movedUs = after.positionUs - before.positionUs;
2910
+ if (Math.abs(movedUs - spanUs) <= DISCONTINUITY_US) {
2911
+ const t = (atNs - before.atNs) / (after.atNs - before.atNs);
2912
+ return before.positionUs + t * movedUs;
2913
+ }
2914
+ return before.positionUs + (atNs - before.atNs) / 1e3;
2915
+ }
2916
+ const nearest = before ?? after;
2917
+ if (nearest === null) return null;
2918
+ if (Math.abs(nearest.atNs - atNs) > windowNs) return null;
2919
+ return nearest.positionUs + (atNs - nearest.atNs) / 1e3;
2920
+ }
2921
+ var DISCONTINUITY_US = 1e5;
2922
+ function scoreSubmissions(submissions, samples, options = {}) {
2923
+ const toleranceUs = options.toleranceUs ?? 6e4;
2924
+ const pairWindowNs = options.pairWindowNs ?? 5e7;
2925
+ const scored = [];
2926
+ const excursions = [];
2927
+ let unpaired = 0;
2928
+ let worstErrorUs = 0;
2929
+ let peakOccupancy = 0;
2930
+ for (const record2 of submissions) {
2931
+ if (record2.ptsUs < 0 || record2.submittedNs <= 0) continue;
2932
+ if (record2.occupancy > peakOccupancy) peakOccupancy = record2.occupancy;
2933
+ const audioUs = audioAt(samples, record2.submittedNs, pairWindowNs);
2934
+ if (audioUs === null) {
2935
+ unpaired += 1;
2936
+ continue;
2937
+ }
2938
+ const start = record2.ptsUs;
2939
+ const end = record2.ptsUs + Math.max(record2.intervalUs, 0);
2940
+ const signedUs = audioUs < start ? audioUs - start : audioUs > end ? audioUs - end : 0;
2941
+ const errorUs = Math.abs(signedUs);
2942
+ const entry = {
2943
+ frameId: record2.frameId,
2944
+ ptsUs: record2.ptsUs,
2945
+ intervalUs: record2.intervalUs,
2946
+ submittedNs: record2.submittedNs,
2947
+ audioUs: Math.round(audioUs),
2948
+ errorUs: Math.round(errorUs),
2949
+ signedUs: Math.round(signedUs),
2950
+ occupancy: record2.occupancy
2951
+ };
2952
+ scored.push(entry);
2953
+ if (errorUs > worstErrorUs) worstErrorUs = errorUs;
2954
+ if (errorUs > toleranceUs) excursions.push(entry);
2955
+ }
2956
+ const gaps = [];
2957
+ const frameTimes = [];
2958
+ for (let i = 1; i < scored.length; i += 1) {
2959
+ const gap = scored[i].ptsUs - scored[i - 1].ptsUs;
2960
+ if (gap > 0) gaps.push(gap);
2961
+ const wall = (scored[i].submittedNs - scored[i - 1].submittedNs) / 1e3;
2962
+ if (wall > 0) frameTimes.push(wall);
2963
+ }
2964
+ gaps.sort((a, b) => a - b);
2965
+ frameTimes.sort((a, b) => a - b);
2966
+ const at = (fraction) => frameTimes.length === 0 ? 0 : Math.round(frameTimes[Math.min(frameTimes.length - 1, Math.floor(frameTimes.length * fraction))]);
2967
+ return {
2968
+ toleranceUs,
2969
+ scored: scored.length,
2970
+ unpaired,
2971
+ excursions,
2972
+ worstErrorUs: Math.round(worstErrorUs),
2973
+ // No submissions is not a pass: a run that showed nothing was not judged.
2974
+ pass: scored.length > 0 && excursions.length === 0,
2975
+ verdict: scored.length === 0 ? "invalid" : excursions.length === 0 ? "pass" : "fail",
2976
+ verdictReason: scored.length === 0 ? "no submissions scored" : excursions.length === 0 ? "" : `${excursions.length} excursions, worst ${Math.round(worstErrorUs / 1e3)} ms`,
2977
+ cadenceUs: {
2978
+ median: gaps.length > 0 ? gaps[Math.floor(gaps.length / 2)] : 0,
2979
+ min: gaps.length > 0 ? gaps[0] : 0,
2980
+ max: gaps.length > 0 ? gaps[gaps.length - 1] : 0,
2981
+ samples: gaps.length
2982
+ },
2983
+ frameTimeUs: {
2984
+ p50: at(0.5),
2985
+ p95: at(0.95),
2986
+ p99: at(0.99),
2987
+ max: frameTimes.length > 0 ? Math.round(frameTimes[frameTimes.length - 1]) : 0,
2988
+ samples: frameTimes.length
2989
+ },
2990
+ peakOccupancy
2991
+ };
2992
+ }
2993
+ function traceSubmission(records, scored, frameId, span = 8) {
2994
+ const ordered = records.filter((r) => r.submittedNs > 0).sort((a, b) => a.submittedNs - b.submittedNs);
2995
+ const centre = ordered.findIndex((r) => r.frameId === frameId);
2996
+ if (centre < 0) {
2997
+ return {
2998
+ frameId,
2999
+ rows: [],
3000
+ spentOn: "unknown",
3001
+ spentUs: 0,
3002
+ invalid: true,
3003
+ reason: "frame not retained"
3004
+ };
3005
+ }
3006
+ const from = Math.max(0, centre - span);
3007
+ const window = ordered.slice(from, centre + span + 1);
3008
+ const base = window[0].submittedNs;
3009
+ const ms = (ns) => ns > 0 ? Math.round((ns - base) / 1e6) : -1;
3010
+ const rows = window.map((r) => {
3011
+ const match = scored.find((s) => s.frameId === r.frameId);
3012
+ return {
3013
+ frameId: r.frameId,
3014
+ acquiredMs: ms(r.acquiredNs),
3015
+ dueMs: ms(r.dueNs),
3016
+ promotedMs: ms(r.promotedNs),
3017
+ submittedMs: ms(r.submittedNs),
3018
+ waitUs: r.acquiredNs > 0 && r.dueNs > 0 ? Math.round((r.dueNs - r.acquiredNs) / 1e3) : -1,
3019
+ promoteUs: r.dueNs > 0 && r.promotedNs > 0 ? Math.round((r.promotedNs - r.dueNs) / 1e3) : -1,
3020
+ submitUs: r.promotedNs > 0 && r.submittedNs > 0 ? Math.round((r.submittedNs - r.promotedNs) / 1e3) : -1,
3021
+ ptsUs: r.ptsUs,
3022
+ audioUs: match === void 0 ? -1 : match.audioUs,
3023
+ errorUs: match === void 0 ? -1 : match.errorUs,
3024
+ slot: r.slot,
3025
+ occupancy: r.occupancy,
3026
+ marker: r.submittedMarker
3027
+ };
3028
+ });
3029
+ const target = rows.find((r) => r.frameId === frameId);
3030
+ const raw = window.find((r) => r.frameId === frameId);
3031
+ if (raw.acquiredNs <= 0 || raw.dueNs <= 0 || raw.promotedNs <= 0) {
3032
+ return {
3033
+ frameId,
3034
+ rows,
3035
+ spentOn: "unknown",
3036
+ spentUs: 0,
3037
+ invalid: true,
3038
+ reason: "incomplete stamps"
3039
+ };
3040
+ }
3041
+ const index = rows.indexOf(target);
3042
+ const previous = index > 0 ? rows[index - 1] : null;
3043
+ const arrivalUs = previous === null ? 0 : Math.round((target.acquiredMs - previous.submittedMs) * 1e3);
3044
+ const promoteUs = target.waitUs < 0 ? target.promoteUs + target.waitUs : target.promoteUs;
3045
+ const legs = [
3046
+ ["arrival", arrivalUs],
3047
+ ["due", Math.max(0, target.waitUs)],
3048
+ ["promotion", promoteUs],
3049
+ ["submission", target.submitUs]
3050
+ ];
3051
+ let spentOn = "unknown";
3052
+ let worst = -1;
3053
+ for (const [name, value] of legs) {
3054
+ if (value > worst) {
3055
+ worst = value;
3056
+ spentOn = name;
3057
+ }
3058
+ }
3059
+ return { frameId, rows, spentOn, spentUs: Math.max(0, worst), invalid: false, reason: "" };
3060
+ }
3061
+ function traceSeam(samples, atUs, spanUs = 6e5) {
3062
+ const near = samples.filter(
3063
+ (s) => s.framePtsUs >= 0 && s.framePtsUs >= atUs - spanUs && s.framePtsUs <= atUs + spanUs && s.frameConsumedNs > 0
3064
+ ).sort((a, b) => a.frameConsumedNs - b.frameConsumedNs);
3065
+ const unique = [];
3066
+ for (const s of near) {
3067
+ if (unique.length === 0 || unique[unique.length - 1].frameId !== s.frameId) unique.push(s);
3068
+ }
3069
+ if (unique.length < 3) {
3070
+ return {
3071
+ atUs,
3072
+ outputGapUs: -1,
3073
+ acquireGapUs: -1,
3074
+ owner: "unknown",
3075
+ stages: [],
3076
+ invalid: true,
3077
+ reason: "too few frames around the boundary"
3078
+ };
3079
+ }
3080
+ const base = unique[0].frameConsumedNs;
3081
+ const ms = (ns) => ns > 0 ? Math.round((ns - base) / 1e6) : -1;
3082
+ const stages = unique.map((s) => ({
3083
+ framePtsUs: s.framePtsUs,
3084
+ metadataMs: ms(s.frameMetadataNs),
3085
+ availableMs: ms(s.frameAvailableNs),
3086
+ consumedMs: ms(s.frameConsumedNs),
3087
+ graphUs: s.frameMetadataNs > 0 && s.frameAvailableNs > 0 ? Math.round((s.frameAvailableNs - s.frameMetadataNs) / 1e3) : -1,
3088
+ latchUs: s.frameAvailableNs > 0 && s.frameConsumedNs > 0 ? Math.round((s.frameConsumedNs - s.frameAvailableNs) / 1e3) : -1
3089
+ }));
3090
+ let outputGapUs = 0;
3091
+ let acquireGapUs = 0;
3092
+ let at = -1;
3093
+ for (let i = 1; i < unique.length; i += 1) {
3094
+ const acquire = Math.round((unique[i].frameConsumedNs - unique[i - 1].frameConsumedNs) / 1e3);
3095
+ if (acquire > acquireGapUs) {
3096
+ acquireGapUs = acquire;
3097
+ at = i;
3098
+ }
3099
+ const a = unique[i].frameMetadataNs;
3100
+ const b = unique[i - 1].frameMetadataNs;
3101
+ if (a > 0 && b > 0) outputGapUs = Math.max(outputGapUs, Math.round((a - b) / 1e3));
3102
+ }
3103
+ let owner = "unknown";
3104
+ if (at > 0) {
3105
+ const s = stages[at];
3106
+ if (s.graphUs < 0 || s.latchUs < 0) owner = "unknown";
3107
+ else if (s.graphUs >= s.latchUs && s.graphUs > 16e3) owner = "graph";
3108
+ else if (s.latchUs > 16e3) owner = "consumer";
3109
+ else owner = "production";
3110
+ }
3111
+ return { atUs, outputGapUs, acquireGapUs, owner, stages, invalid: false, reason: "" };
3112
+ }
3113
+ function traceStartup(samples, records, options) {
3114
+ const count = options.count ?? 10;
3115
+ if (options.sourceFps <= 0 || options.speed <= 0) {
3116
+ return { rows: [], missingSourceFrames: [], misassociated: [], invalid: true, reason: "no clip rate" };
3117
+ }
3118
+ const stepUs = 1e6 / options.sourceFps / options.speed;
3119
+ const byFrame = /* @__PURE__ */ new Map();
3120
+ for (const s of samples) {
3121
+ if (s.frameId < 0 || s.frameConsumedNs <= 0) continue;
3122
+ const seen = byFrame.get(s.frameId);
3123
+ if (seen === void 0 || s.frameConsumedNs < seen.frameConsumedNs) byFrame.set(s.frameId, s);
3124
+ }
3125
+ const ordered = records.filter((r) => r.submittedNs > 0).sort((a, b) => a.submittedNs - b.submittedNs).slice(0, count);
3126
+ if (ordered.length === 0) {
3127
+ return { rows: [], missingSourceFrames: [], misassociated: [], invalid: true, reason: "no submissions" };
3128
+ }
3129
+ const base = ordered[0].submittedNs;
3130
+ const ms = (ns) => ns > 0 ? Math.round((ns - base) / 1e6) : -1;
3131
+ const rows = ordered.map((r) => {
3132
+ const s = byFrame.get(r.frameId);
3133
+ return {
3134
+ frameId: r.frameId,
3135
+ renderedPtsUs: s === void 0 ? -1 : s.framePtsUs,
3136
+ recordedPtsUs: r.ptsUs,
3137
+ marker: r.submittedMarker,
3138
+ markerPtsUs: r.submittedMarker >= 0 ? Math.round(r.submittedMarker * stepUs) : -1,
3139
+ metadataMs: s === void 0 ? -1 : ms(s.frameMetadataNs),
3140
+ consumedMs: s === void 0 ? -1 : ms(s.frameConsumedNs),
3141
+ submittedMs: ms(r.submittedNs)
3142
+ };
3143
+ });
3144
+ const misassociated = [];
3145
+ const rendered = /* @__PURE__ */ new Set();
3146
+ for (const row of rows) {
3147
+ if (row.renderedPtsUs >= 0) rendered.add(Math.round(row.renderedPtsUs / stepUs));
3148
+ if (row.marker < 0 || row.renderedPtsUs < 0) continue;
3149
+ if (Math.abs(row.markerPtsUs - row.renderedPtsUs) > stepUs / 2) misassociated.push(row.frameId);
3150
+ }
3151
+ const last = rows[rows.length - 1];
3152
+ const highest = Math.max(
3153
+ last.marker >= 0 ? last.marker : 0,
3154
+ last.renderedPtsUs >= 0 ? Math.round(last.renderedPtsUs / stepUs) : 0
3155
+ );
3156
+ const missingSourceFrames = [];
3157
+ for (let i = 0; i <= highest; i += 1) if (!rendered.has(i)) missingSourceFrames.push(i);
3158
+ return { rows, missingSourceFrames, misassociated, invalid: false, reason: "" };
3159
+ }
3160
+
3161
+ // src/usePreviewAudio.ts
3162
+ import {
3163
+ compileAudioMixProgram as compileAudioMixProgram3
3164
+ } from "@pictor-sdk/video-core";
3165
+ import { useCallback as useCallback4, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState4 } from "react";
3166
+
3167
+ // src/previewAudioDiagnostics.ts
3168
+ var previewAudioDiagnostics = {
3169
+ suppressPublications: false,
3170
+ logPublications: false,
3171
+ published: 0,
3172
+ suppressed: 0
3173
+ };
3174
+ function publishState(name, set) {
3175
+ return (value) => {
3176
+ if (previewAudioDiagnostics.suppressPublications) {
3177
+ previewAudioDiagnostics.suppressed += 1;
3178
+ return;
3179
+ }
3180
+ previewAudioDiagnostics.published += 1;
3181
+ if (previewAudioDiagnostics.logPublications) {
3182
+ console.log(`AUDIOPUB ${name} ${JSON.stringify(value ?? null)}`);
3183
+ }
3184
+ set(value);
3185
+ };
3186
+ }
3187
+
3188
+ // src/usePreviewAudio.ts
3189
+ function usePreviewAudio(options) {
3190
+ const { project, enabled = true } = options;
3191
+ const resolveSource = useRef4(options.resolveSource);
3192
+ resolveSource.current = options.resolveSource;
3193
+ const [status, setStatusState] = useState4("source");
3194
+ const [error, setErrorState] = useState4(null);
3195
+ const [readyKey, setReadyKeyState] = useState4(null);
3196
+ const [lastPrepareMs, setLastPrepareMsState] = useState4(null);
3197
+ const [lastProfile, setLastProfileState] = useState4(null);
3198
+ const [mix, setMix] = useState4(null);
3199
+ const setStatus = useMemo3(() => publishState("status", setStatusState), [setStatusState]);
3200
+ const setError = useMemo3(() => publishState("error", setErrorState), [setErrorState]);
3201
+ const setReadyKey = useMemo3(
3202
+ () => publishState("readyKey", setReadyKeyState),
3203
+ [setReadyKeyState]
3204
+ );
3205
+ const setLastPrepareMs = useMemo3(
3206
+ () => publishState("lastPrepareMs", setLastPrepareMsState),
3207
+ [setLastPrepareMsState]
3208
+ );
3209
+ const setLastProfile = useMemo3(
3210
+ () => publishState("lastProfile", setLastProfileState),
3211
+ [setLastProfileState]
3212
+ );
3213
+ const preparingKey = useRef4(null);
3214
+ const failedKey = useRef4(null);
3215
+ const epoch = useRef4(0);
3216
+ const mounted = useRef4(true);
3217
+ const wanted = useMemo3(() => {
3218
+ if (!enabled || project.audio.length === 0) {
3219
+ return {
3220
+ key: "",
3221
+ programJson: "",
3222
+ urisJson: "",
3223
+ durationUs: 0,
3224
+ unresolved: []
3225
+ };
3226
+ }
3227
+ const program = compileAudioMixProgram3(project);
3228
+ const resolved = resolveMixSources(project, program, (s, c) => resolveSource.current(s, c));
3229
+ const programJson = JSON.stringify(program);
3230
+ const urisJson = JSON.stringify(resolved.uris);
3231
+ return {
3232
+ key: resolved.unresolved.length > 0 ? "" : previewMixKey(programJson, urisJson),
3233
+ programJson,
3234
+ urisJson,
3235
+ durationUs: Number(program.durationUs),
3236
+ unresolved: resolved.unresolved
3237
+ };
3238
+ }, [project, enabled]);
3239
+ const decision = decidePreviewAudio({
3240
+ wantedKey: wanted.key,
3241
+ readyKey,
3242
+ preparingKey: preparingKey.current,
3243
+ failedKey: failedKey.current
3244
+ });
3245
+ const unresolved = wanted.unresolved.length > 0 && enabled && project.audio.length > 0;
3246
+ useEffect4(() => {
3247
+ mounted.current = true;
3248
+ return () => {
3249
+ mounted.current = false;
3250
+ };
3251
+ }, []);
3252
+ useEffect4(() => {
3253
+ if (!enabled) return;
3254
+ try {
3255
+ requireNative().previewMuteSourceAudio(decision.muteSourceAudio || unresolved);
3256
+ } catch {
3257
+ }
3258
+ }, [decision.muteSourceAudio, unresolved, enabled]);
3259
+ useEffect4(() => {
3260
+ if (unresolved) {
3261
+ setStatus("failed");
3262
+ setError(wanted.unresolved[0] ?? "a sound could not be read");
3263
+ return;
3264
+ }
3265
+ setStatus(decision.status);
3266
+ if (decision.status !== "failed") setError(null);
3267
+ }, [decision.status, unresolved, wanted.unresolved, setError, setStatus]);
3268
+ useEffect4(() => {
3269
+ if (!decision.shouldPrepare || unresolved) return;
3270
+ const key = wanted.key;
3271
+ preparingKey.current = key;
3272
+ const jobId = `preview-${key.slice(0, 8)}-${epoch.current + 1}`;
3273
+ const startedAt = Date.now();
3274
+ void preparePreviewMix(jobId, wanted.programJson, wanted.urisJson).then((result) => {
3275
+ if (!mounted.current) return;
3276
+ if (result.ok === false && result.superseded) return;
3277
+ if (key !== preparingKey.current) return;
3278
+ preparingKey.current = null;
3279
+ if (result.ok === false) {
3280
+ failedKey.current = key;
3281
+ setError(result.reason);
3282
+ setStatus("failed");
3283
+ return;
3284
+ }
3285
+ failedKey.current = null;
3286
+ epoch.current += 1;
3287
+ setLastPrepareMs(result.cached ? null : Date.now() - startedAt);
3288
+ setLastProfile(result.cached ? null : result.profile ?? null);
3289
+ try {
3290
+ requireNative().previewAudioLoad(result.uri, epoch.current);
3291
+ } catch {
3292
+ failedKey.current = key;
3293
+ setError("the prepared mix could not be opened");
3294
+ setStatus("failed");
3295
+ return;
3296
+ }
3297
+ setMix({ uri: result.uri, durationUs: Number(wanted.durationUs) });
3298
+ setReadyKey(result.key);
3299
+ sweepPreviewMixes([result.key]);
3300
+ });
3301
+ }, [
3302
+ decision.shouldPrepare,
3303
+ wanted.key,
3304
+ wanted.programJson,
3305
+ wanted.urisJson,
3306
+ unresolved,
3307
+ setError,
3308
+ setLastPrepareMs,
3309
+ setLastProfile,
3310
+ setReadyKey,
3311
+ setStatus
3312
+ ]);
3313
+ const release = useCallback4(() => {
3314
+ cancelPreviewMix();
3315
+ try {
3316
+ requireNative().previewSyncStop("release");
3317
+ requireNative().previewAudioRelease();
3318
+ requireNative().previewMuteSourceAudio(false);
3319
+ } catch {
3320
+ }
3321
+ preparingKey.current = null;
3322
+ setReadyKey(null);
3323
+ setMix(null);
3324
+ }, [setReadyKey]);
3325
+ useEffect4(() => release, [release]);
3326
+ return {
3327
+ status: unresolved ? "failed" : status,
3328
+ error,
3329
+ canPlay: !unresolved && decision.canPlay,
3330
+ ready: decision.status === "ready",
3331
+ lastPrepareMs,
3332
+ lastProfile,
3333
+ // Null until a mix matching the document is ready, so a composition built
3334
+ // from it is never built from a stale one.
3335
+ mix: decision.status === "ready" ? mix : null,
3336
+ release
3337
+ };
3338
+ }
3339
+
3340
+ // src/encoderGate.ts
3341
+ var ENCODER_GATE_FIXTURES = [
3342
+ "short",
3343
+ "acceptance",
3344
+ "silence",
3345
+ "saturated",
3346
+ "markers"
3347
+ ];
3348
+ function runEncoderGate(fixture, maxInputSize = 65536) {
3349
+ try {
3350
+ return JSON.parse(requireNative().encoderGateRun(fixture, maxInputSize));
3351
+ } catch (error) {
3352
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
3353
+ }
3354
+ }
3355
+
3356
+ // src/previewTrace.ts
3357
+ var EMPTY2 = {
3358
+ ok: false,
3359
+ valid: false,
3360
+ fault: "trace unavailable",
3361
+ events: [],
3362
+ nextSeq: 0,
3363
+ total: 0,
3364
+ dropped: 0
3365
+ };
3366
+ function armPreviewTrace(on, intervalMs = 25) {
3367
+ try {
3368
+ const value = JSON.parse(requireNative().previewTraceArm(on, intervalMs));
3369
+ return value.ok === true;
3370
+ } catch {
3371
+ return false;
3372
+ }
3373
+ }
3374
+ function markPreviewTrace(label) {
3375
+ try {
3376
+ requireNative().previewTraceMark(label);
3377
+ } catch {
3378
+ }
3379
+ }
3380
+ function drainPreviewTrace(fromSeq, limit = 200) {
3381
+ try {
3382
+ const value = JSON.parse(requireNative().previewTraceDrain(fromSeq, limit));
3383
+ return value.ok ? value : EMPTY2;
3384
+ } catch {
3385
+ return EMPTY2;
3386
+ }
3387
+ }
3388
+ function drainAllPreviewTrace(limit = 200) {
3389
+ const events = [];
3390
+ let seq = 0;
3391
+ let dropped = 0;
3392
+ let total = 0;
3393
+ let valid = true;
3394
+ let fault = null;
3395
+ for (let guard = 0; guard < 200; guard += 1) {
3396
+ const page = drainPreviewTrace(seq, limit);
3397
+ if (page.fault) {
3398
+ valid = false;
3399
+ fault = page.fault;
3400
+ }
3401
+ if (!page.ok || page.events.length === 0) {
3402
+ total = page.total || total;
3403
+ break;
3404
+ }
3405
+ events.push(...page.events);
3406
+ dropped += page.dropped;
3407
+ total = page.total;
3408
+ if (page.nextSeq === seq) break;
3409
+ seq = page.nextSeq;
3410
+ }
3411
+ return { ok: true, valid, fault, events, nextSeq: seq, total, dropped };
3412
+ }
3413
+ function summariseOffsets(events) {
3414
+ let n = 0;
3415
+ let stale = 0;
3416
+ let absent = 0;
3417
+ let fresh = 0;
3418
+ let staleRun = 0;
3419
+ let longestStaleRun = 0;
3420
+ let min = Number.POSITIVE_INFINITY;
3421
+ let max = Number.NEGATIVE_INFINITY;
3422
+ let sum = 0;
3423
+ let samples = 0;
3424
+ for (const e of events) {
3425
+ if (e.type !== "sample") continue;
3426
+ samples += 1;
3427
+ if (e.v.pfFresh === void 0) {
3428
+ absent += 1;
3429
+ staleRun += 1;
3430
+ if (staleRun > longestStaleRun) longestStaleRun = staleRun;
3431
+ } else if (e.v.pfFresh === false) {
3432
+ stale += 1;
3433
+ staleRun += 1;
3434
+ if (staleRun > longestStaleRun) longestStaleRun = staleRun;
3435
+ } else {
3436
+ fresh += 1;
3437
+ staleRun = 0;
3438
+ }
3439
+ if (e.v.videoPlaying !== true || e.v.audioPlaying !== true) continue;
3440
+ const offset = Number(e.v.offsetUs);
3441
+ if (!Number.isFinite(offset)) continue;
3442
+ n += 1;
3443
+ sum += offset;
3444
+ if (offset < min) min = offset;
3445
+ if (offset > max) max = offset;
3446
+ }
3447
+ return {
3448
+ samples,
3449
+ playingSamples: n,
3450
+ staleFrameSamples: stale,
3451
+ absentFrameSamples: absent,
3452
+ freshFrameSamples: fresh,
3453
+ longestStaleRun,
3454
+ minUs: n > 0 ? min : 0,
3455
+ maxUs: n > 0 ? max : 0,
3456
+ meanUs: n > 0 ? Math.round(sum / n) : 0,
3457
+ worstAbsUs: n > 0 ? Math.max(Math.abs(min), Math.abs(max)) : 0
3458
+ };
3459
+ }
3460
+ function previewSyncCounters() {
3461
+ try {
3462
+ return JSON.parse(requireNative().previewSyncCounters());
3463
+ } catch {
3464
+ return { ok: false };
3465
+ }
3466
+ }
3467
+ function stopPreviewForHost(reason) {
3468
+ try {
3469
+ requireNative().previewSyncStop(reason);
3470
+ } catch {
3471
+ }
3472
+ }
3473
+ function previewFramePipeline() {
3474
+ try {
3475
+ return JSON.parse(requireNative().previewFramePipeline());
3476
+ } catch {
3477
+ return {};
3478
+ }
3479
+ }
3480
+ function setPreviewParts(parts) {
3481
+ try {
3482
+ const v = JSON.parse(requireNative().previewConfigSet(JSON.stringify(parts)));
3483
+ return v.ok ? v : null;
3484
+ } catch {
3485
+ return null;
3486
+ }
3487
+ }
3488
+ function previewParts() {
3489
+ try {
3490
+ return JSON.parse(requireNative().previewConfigGet());
3491
+ } catch {
3492
+ return null;
3493
+ }
3494
+ }
3495
+ function muteSourceAudio(muted) {
3496
+ try {
3497
+ requireNative().previewMuteSourceAudio(muted);
3498
+ } catch {
3499
+ }
3500
+ }
3501
+ function previewCodecLedger() {
3502
+ try {
3503
+ return JSON.parse(requireNative().previewCodecLedger());
3504
+ } catch {
3505
+ return { live: -1, peakLive: -1, total: 0, entries: [] };
3506
+ }
3507
+ }
3508
+ export {
3509
+ AndroidVideoPlayer,
3510
+ CONVERGED_RUN,
3511
+ DEFAULT_CEILING_BYTES,
3512
+ DEFAULT_RISE_BYTES,
3513
+ DEFAULT_WINDOW_SAMPLES,
3514
+ ENCODER_GATE_FIXTURES,
3515
+ EXPORT_STAGES,
3516
+ MAX_OBSERVED_HEARTBEAT_GAP_MS,
3517
+ MAX_RECOVERY_SAMPLES,
3518
+ Media3,
3519
+ MediaSaveCancelled,
3520
+ MediaSaveFailed,
3521
+ NO_SURFACE,
3522
+ PHASE_MARK_PREFIX,
3523
+ PREVIEW_DRIFT_TOLERANCE_US,
3524
+ PREVIEW_OUTPUT_FPS,
3525
+ PREVIEW_TOLERANCE_US,
3526
+ RECOVERY_GRACE_MS,
3527
+ SPIKE_TOLERANCE_US,
3528
+ STALL_GRACE_MS,
3529
+ VideoExportRejected,
3530
+ afterPrepare,
3531
+ armPreviewTrace,
3532
+ beginPhase,
3533
+ cancelPreviewMix,
3534
+ checkCadence,
3535
+ classifyExcursions,
3536
+ classifyStall,
3537
+ compositionSpikeSpec,
3538
+ createAndroidThumbnailProvider,
3539
+ createExportJobs,
3540
+ createMedia3VideoExporter,
3541
+ createMediaStoreSaver,
3542
+ createMemoryGuard,
3543
+ createOverlayStagingSink,
3544
+ createPlaybackLifecycle,
3545
+ createProgressReporter,
3546
+ createStallWatchdog,
3547
+ createSurfaceOwner,
3548
+ createSyncTrace,
3549
+ decidePreviewAudio,
3550
+ describeTrip,
3551
+ distribution,
3552
+ drainAllPreviewTrace,
3553
+ drainPreviewTrace,
3554
+ expectedPtsAt,
3555
+ idlePlaylistSession,
3556
+ ingestManagedSource,
3557
+ installOverlayStaging,
3558
+ isCurrentGeneration,
3559
+ managedSourceLedger,
3560
+ managedSourceList,
3561
+ managedSourceToken,
3562
+ markPreviewTrace,
3563
+ measureStagingStall,
3564
+ mixSpanContext,
3565
+ muteSourceAudio,
3566
+ nextOwnerToken,
3567
+ observe,
3568
+ observeMemory,
3569
+ offsetAt,
3570
+ planProgressWeights,
3571
+ preparePreviewMix,
3572
+ previewAudioDiagnostics,
3573
+ previewAudioNeeded,
3574
+ previewCodecLedger,
3575
+ previewFramePipeline,
3576
+ previewMixKey,
3577
+ previewMixLedger,
3578
+ previewParts,
3579
+ previewProgramJson,
3580
+ previewSyncCounters,
3581
+ probeAudioSource,
3582
+ record as recordTick,
3583
+ recoveryFrames,
3584
+ releaseManagedSource,
3585
+ releasePreviewMix,
3586
+ resetSurfaceOwnerForTests,
3587
+ resolveMixSources,
3588
+ retireSurface,
3589
+ runEncoderGate,
3590
+ scoreSubmissions,
3591
+ seedManagedSourceForGate,
3592
+ setPreviewParts,
3593
+ shareSavedMedia,
3594
+ shouldPrepare,
3595
+ shouldRecover,
3596
+ spikeClaim,
3597
+ spikeDrain,
3598
+ spikePause,
3599
+ spikePlay,
3600
+ spikeRelease,
3601
+ spikeSeek,
3602
+ spikeStart,
3603
+ spikeState,
3604
+ stopPreviewForHost,
3605
+ summariseOffsets,
3606
+ summarisePhases,
3607
+ surfaceOwner,
3608
+ sweepManagedSources,
3609
+ sweepManagedSourcesRawForGate,
3610
+ sweepOverlayStaging,
3611
+ sweepPendingMediaSaves,
3612
+ sweepPreviewMixes,
3613
+ syncReport,
3614
+ thumbnailCounters,
3615
+ traceSeam,
3616
+ traceStartup,
3617
+ traceSubmission,
3618
+ useCompositionPlayback,
3619
+ useMedia3Playback,
3620
+ usePreviewAudio,
3621
+ useVideoSurface,
3622
+ videoSurfaceSlot,
3623
+ windowAround
3624
+ };
3625
+ //# sourceMappingURL=index.mjs.map