@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/src/media3.ts ADDED
@@ -0,0 +1,412 @@
1
+ import { type PlaylistResult } from '@pictor-sdk/video-core'
2
+ import { requireNative } from './player'
3
+
4
+ /**
5
+ * Playback backed by Media3's ExoPlayer.
6
+ *
7
+ * Media3 owns decoding, buffering, audio focus, synchronisation, seeking, speed
8
+ * and lifecycle. Pictor owns the document, the timeline and the Skia
9
+ * composition. This is the whole of the seam between them.
10
+ *
11
+ * **No Media3 or Android type appears here.** Positions are microseconds
12
+ * because that is Pictor's unit; the adapter converts.
13
+ *
14
+ * Video is rendered into the persistent external texture, so the renderer holds
15
+ * one `SkImage` for a whole session and nothing is imported per frame.
16
+ *
17
+ * @experimental
18
+ */
19
+ export interface Media3State {
20
+ positionUs: number
21
+ durationUs: number
22
+ playing: boolean
23
+ buffering: boolean
24
+ ready: boolean
25
+ ended: boolean
26
+ rate: number
27
+ width: number
28
+ height: number
29
+ released: boolean
30
+ error: string
31
+ /** Bumped on seek, source change and release; pairs frames with a clip. */
32
+ generation: number
33
+ /** Frame metadata awaiting a renderer. Bounded. */
34
+ queueDepth: number
35
+ /** Metadata dropped because the queue was full. Should stay zero. */
36
+ overflowed: number
37
+ /** Frames consumed with no sample to pair. Expected briefly after a seek. */
38
+ unpaired: number
39
+ /** Samples discarded because their generation was superseded. */
40
+ stale: number
41
+ queueHighWater: number
42
+ /** Live threads in the process. Diagnostics for leak attribution. */
43
+ threads: number
44
+ /** Allocated native heap, for the safety cutoff. */
45
+ nativeHeapBytes: number
46
+ /** The anchor's own position, before extrapolation. Diagnostics. */
47
+ anchorPositionUs: number
48
+ /** Frames the buffer queue dropped because the renderer was behind. */
49
+ coalesced: number
50
+ /** How coarse `Player.currentPosition` is, measured rather than assumed. */
51
+ anchorPublications: number
52
+ anchorChanges: number
53
+ anchorMaxStepUs: number
54
+ anchorMeanStepUs: number
55
+ anchorMaxStaleNs: number
56
+ /** Which playlist window is playing, and how many there are. */
57
+ windowIndex: number
58
+ windowCount: number
59
+ /** Offset inside the current window, in microseconds. */
60
+ windowPositionUs: number
61
+ /** Window transitions so far — seams crossed. Diagnostics. */
62
+ transitions: number
63
+ /**
64
+ * Frames whose latch failed and was retried on the next tick.
65
+ *
66
+ * `updateTexImage` returns quietly when Skia's GL context is not current on
67
+ * the calling thread, so this counts the retries rather than letting the
68
+ * frames disappear. A handful around startup and playlist changes is normal;
69
+ * a rate that tracks the frame rate is not.
70
+ */
71
+ missedLatches: number
72
+ /**
73
+ * What the single latch owner did, per frame.
74
+ *
75
+ * `skipped` counts ticks refused because the surface was retired or replaced
76
+ * — the invalid call, prevented rather than caught. `failed` must stay
77
+ * visible: a latch that quietly does nothing looks exactly like a paused
78
+ * video, which is how an earlier defect hid for a whole session.
79
+ */
80
+ updatesAttempted: number
81
+ updatesSucceeded: number
82
+ updatesSkipped: number
83
+ updatesFailed: number
84
+ /**
85
+ * Times a requested rate exceeded the range and had to be clamped.
86
+ *
87
+ * Should stay zero: the UI only offers rates that compose within range. A
88
+ * non-zero value means the picture and the timeline are counting differently.
89
+ */
90
+ rateConstrained: number
91
+ /** The rate actually in force: clip speed times preview rate, clamped. */
92
+ effectiveRate: number
93
+ /**
94
+ * The rate asked for, before clamping.
95
+ *
96
+ * Beside `effectiveRate` this says whether a ceiling was *selected* or merely
97
+ * reached: 10 requested and 10 effective is a legitimate extreme, while 25
98
+ * requested and 10 effective is a refusal.
99
+ */
100
+ requestedRate: number
101
+ /**
102
+ * Window indices that had no speed. Should stay zero.
103
+ *
104
+ * Non-zero means the playlist and the window index were read at different
105
+ * moments — the shape that left a cleared playlist running at the rate of the
106
+ * playlist before it.
107
+ */
108
+ staleWindowSpeeds: number
109
+ /** How many per-window speeds the player holds. Zero when idle. */
110
+ windowSpeedCount: number
111
+ /**
112
+ * The two halves of frame delivery, counted separately.
113
+ *
114
+ * `framesOffered` is Media3 telling us a frame is about to be released;
115
+ * `framesAvailable` is the SurfaceTexture saying one arrived. If the first
116
+ * climbs while the second does not, the decoder is running but its output is
117
+ * not reaching our surface — which is a completely different fault from the
118
+ * renderer failing to pull.
119
+ */
120
+ framesOffered: number
121
+ framesAvailable: number
122
+ /**
123
+ * Frames waiting to be latched, right now.
124
+ *
125
+ * A level rather than an edge, so it survives the consumer being disarmed.
126
+ * Usually 0 or 1; larger after a background, and it must fall to 0 within a
127
+ * tick of the consumer re-arming.
128
+ */
129
+ pendingDepth: number
130
+ /** Availability announced for a generation already retired. Should stay 0. */
131
+ framesRejected: number
132
+ /**
133
+ * The generation the pending counters are keeping for.
134
+ *
135
+ * Must equal the consumer's generation. A frame pending for a different one
136
+ * is a frame nobody will ever take.
137
+ */
138
+ pendingGeneration: number
139
+ /** Producer identity, for proving which object a consumer is holding. */
140
+ controllerId: number
141
+ pendingId: number
142
+ orphaned: number
143
+ /** `mime WxH rotN @fps` for the active item. Empty when there is no video. */
144
+ videoFormat: string
145
+ /** The decoder Media3 selected, e.g. `c2.exynos.h264.decoder`. */
146
+ decoderName: string
147
+ /**
148
+ * What the decoder was configured for, and what it produced.
149
+ *
150
+ * Read from the player's own events rather than inferred from the device: a
151
+ * mixed-resolution seam is the one moment input and output can disagree, and
152
+ * naming the decoder from the model instead of asking is how an attribution
153
+ * survives without ever being checked.
154
+ */
155
+ inputFormats: number
156
+ inputWidth: number
157
+ inputHeight: number
158
+ inputRotation: number
159
+ /** Media3's decoder-reuse verdict across the last format change; -1 if none. */
160
+ decoderReuse: number
161
+ outputSizes: number
162
+ outputWidth: number
163
+ outputHeight: number
164
+ /** Increments on each decoder init — makes re-attachment visible. */
165
+ surfaceGeneration: number
166
+ /**
167
+ * Seek bookkeeping for the cross-window freeze soak.
168
+ *
169
+ * `seeksRequested` counts calls; `seeksAccepted` counts those the player's
170
+ * own thread acted on. A gap between them, or a `pendingSeek` that stays
171
+ * true, is a seek that never reached the player — which is what the observed
172
+ * freeze would look like from this side.
173
+ */
174
+ /**
175
+ * Surface attachment bookkeeping, for diagnostics only.
176
+ *
177
+ * The adapter attaches automatically the instant a surface becomes live, so a
178
+ * host never calls anything. These exist so the invariant can be *checked*:
179
+ * `attachedSurfaceGeneration` trailing `liveSurfaceGeneration` means the
180
+ * player is rendering into a surface nobody is watching.
181
+ */
182
+ surfaceAttachments: number
183
+ attachedSurfaceGeneration: number
184
+ liveSurfaceGeneration: number
185
+ seeksRequested: number
186
+ seeksAccepted: number
187
+ pendingSeek: boolean
188
+ /** Media3's own playback state constant. 1 idle, 2 buffering, 3 ready, 4 ended. */
189
+ playbackState: number
190
+ /** PTS of the last frame the renderer actually paired, or -1. */
191
+ lastPairedPtsUs: number
192
+ /**
193
+ * Source failures reported by the player, not inferred from the state.
194
+ *
195
+ * A failed source otherwise looks exactly like an idle one — nothing playing,
196
+ * nothing buffering, no duration — so the count is what distinguishes "never
197
+ * started" from "started and broke".
198
+ */
199
+ errorCount: number
200
+ /** Media3's error code, and its name plus message. Empty when healthy. */
201
+ errorCode: number
202
+ errorMessage: string
203
+ /** Whether Media3 classifies the failure as worth retrying. */
204
+ errorRecoverable: boolean
205
+ }
206
+
207
+ const EMPTY: Media3State = {
208
+ positionUs: 0,
209
+ durationUs: 0,
210
+ playing: false,
211
+ buffering: false,
212
+ ready: false,
213
+ ended: false,
214
+ rate: 1,
215
+ width: 0,
216
+ height: 0,
217
+ released: true,
218
+ error: '',
219
+ generation: 0,
220
+ queueDepth: 0,
221
+ overflowed: 0,
222
+ unpaired: 0,
223
+ stale: 0,
224
+ queueHighWater: 0,
225
+ threads: 0,
226
+ nativeHeapBytes: 0,
227
+ anchorPositionUs: 0,
228
+ coalesced: 0,
229
+ anchorPublications: 0,
230
+ anchorChanges: 0,
231
+ anchorMaxStepUs: 0,
232
+ anchorMeanStepUs: 0,
233
+ anchorMaxStaleNs: 0,
234
+ windowIndex: 0,
235
+ windowCount: 0,
236
+ windowPositionUs: 0,
237
+ transitions: 0,
238
+ missedLatches: 0,
239
+ updatesAttempted: 0,
240
+ updatesSucceeded: 0,
241
+ updatesSkipped: 0,
242
+ updatesFailed: 0,
243
+ rateConstrained: 0,
244
+ effectiveRate: 1,
245
+ requestedRate: 1,
246
+ staleWindowSpeeds: 0,
247
+ windowSpeedCount: 0,
248
+ framesOffered: 0,
249
+ framesAvailable: 0,
250
+ pendingDepth: 0,
251
+ framesRejected: 0,
252
+ pendingGeneration: 0,
253
+ controllerId: 0,
254
+ pendingId: 0,
255
+ orphaned: 0,
256
+ videoFormat: '',
257
+ decoderName: '',
258
+ inputFormats: 0,
259
+ inputWidth: 0,
260
+ inputHeight: 0,
261
+ inputRotation: 0,
262
+ decoderReuse: -1,
263
+ outputSizes: 0,
264
+ outputWidth: 0,
265
+ outputHeight: 0,
266
+ surfaceGeneration: 0,
267
+ surfaceAttachments: 0,
268
+ attachedSurfaceGeneration: 0,
269
+ liveSurfaceGeneration: 0,
270
+ seeksRequested: 0,
271
+ seeksAccepted: 0,
272
+ pendingSeek: false,
273
+ playbackState: 0,
274
+ lastPairedPtsUs: -1,
275
+ errorCount: 0,
276
+ errorCode: 0,
277
+ errorMessage: '',
278
+ errorRecoverable: false,
279
+ }
280
+
281
+ /** @experimental */
282
+ export const Media3 = {
283
+ /**
284
+ * Open a local clip and render it into the presentation surface.
285
+ *
286
+ * The surface must already exist: it is created on the UI runtime, where
287
+ * Skia's GL context lives, and preparing without one is an error rather than
288
+ * a silently black picture.
289
+ */
290
+ prepare(uri: string): boolean {
291
+ return requireNative().media3Prepare(uri)
292
+ },
293
+
294
+ /** Diagnostics: a player with no video output, for heap attribution. */
295
+ prepareNoSurface(uri: string): boolean {
296
+ return requireNative().media3PrepareNoSurface(uri)
297
+ },
298
+
299
+ /** Diagnostics: reuse the existing player for another source. */
300
+ rebind(uri: string): boolean {
301
+ return requireNative().media3Rebind(uri)
302
+ },
303
+
304
+ /** Diagnostics: end the source, keep the player. */
305
+ stopKeepingPlayer(): void {
306
+ requireNative().media3StopKeepingPlayer()
307
+ },
308
+
309
+ /**
310
+ * Replace the playlist from a project's playable windows.
311
+ *
312
+ * Takes the `PlaylistResult` straight from `@pictor-sdk/video-core` so the
313
+ * project → window mapping has exactly one definition, tested there as a
314
+ * round trip.
315
+ * The sources must already be URI strings; this adapter does not interpret a
316
+ * host's media descriptors, and a non-URI source is a host normalisation bug
317
+ * rather than something to guess at.
318
+ *
319
+ * The OES texture and the `SkImage` survive this. Changing the playlist is
320
+ * not a reason to rebuild what the renderer holds.
321
+ */
322
+ setPlaylist(
323
+ playlist: PlaylistResult,
324
+ start: { windowIndex: number; positionUs: number } = { windowIndex: 0, positionUs: 0 },
325
+ options: { allowDivergence?: boolean } = {},
326
+ ): boolean {
327
+ // Refuse a playlist that would not play what the timeline shows.
328
+ //
329
+ // An unsupported clip keeps its span on the timeline while contributing
330
+ // nothing to playback, so the two clocks diverge by exactly its duration
331
+ // and every seam after it lands wrong. That is far worse than an error: it
332
+ // looks like a working editor that is slightly out of sync. A host that
333
+ // genuinely wants the playable remainder must ask for it.
334
+ if (playlist.unsupported.length > 0 && options.allowDivergence !== true) {
335
+ throw new Error(
336
+ '@pictor-sdk/video-android: refusing to build a playlist that would diverge from the ' +
337
+ `timeline. ${playlist.unsupported.length} clip(s) cannot play:\n` +
338
+ playlist.unsupported.map((u) => ` · ${u.message}`).join('\n') +
339
+ '\nPass { allowDivergence: true } to play the remainder anyway.',
340
+ )
341
+ }
342
+ const uris: string[] = []
343
+ const bounds: number[] = []
344
+ for (const item of playlist.items) {
345
+ uris.push(uriOf(item.source))
346
+ bounds.push(item.startUs, item.endUs, Math.round(item.speed * 1000), item.muted ? 1 : 0)
347
+ }
348
+ return requireNative().media3SetPlaylist(uris, bounds, start.windowIndex, start.positionUs)
349
+ },
350
+
351
+ /** Seek in the player's coordinates. Pair with `projectTimeToWindow`. */
352
+ seekToWindow(windowIndex: number, positionUs: number): void {
353
+ requireNative().media3SeekToWindow(windowIndex, positionUs)
354
+ },
355
+
356
+ play(): void {
357
+ requireNative().media3Play()
358
+ },
359
+
360
+ pause(): void {
361
+ requireNative().media3Pause()
362
+ },
363
+
364
+ /** Microseconds, like every other time in Pictor. */
365
+ seek(positionUs: number): void {
366
+ requireNative().media3Seek(positionUs)
367
+ },
368
+
369
+ setRate(rate: number): void {
370
+ requireNative().media3SetRate(rate)
371
+ },
372
+
373
+ /**
374
+ * Try the current source again after a failure.
375
+ *
376
+ * This is Media3's own recovery path: it re-resolves the source and resumes
377
+ * from the current position. Worth calling when `errorRecoverable` is true —
378
+ * a network source that went away and came back — and pointless otherwise.
379
+ */
380
+ retry(): void {
381
+ requireNative().media3Retry()
382
+ },
383
+
384
+ release(): void {
385
+ requireNative().media3Release()
386
+ },
387
+
388
+ /** A snapshot for throttled reads. Never call this per frame. */
389
+ state(): Media3State {
390
+ const raw = requireNative().media3State() as Partial<Media3State> | null
391
+ return raw ? { ...EMPTY, ...raw } : EMPTY
392
+ },
393
+ }
394
+
395
+ /**
396
+ * A source as a URI string, or a readable failure.
397
+ *
398
+ * `MediaSource` is deliberately open — the timeline stores whatever descriptor
399
+ * a host uses — so the adapter is where it has to become something Android can
400
+ * open. Failing loudly here is better than handing `undefined` to `Uri.parse`
401
+ * and getting a black picture with no explanation.
402
+ */
403
+ function uriOf(source: unknown): string {
404
+ if (typeof source === 'string') return source
405
+ if (source && typeof source === 'object' && 'uri' in source) {
406
+ const uri = (source as { uri?: unknown }).uri
407
+ if (typeof uri === 'string') return uri
408
+ }
409
+ throw new Error(
410
+ `@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)}`,
411
+ )
412
+ }
@@ -0,0 +1,177 @@
1
+ import type { MediaSaver, SavedMedia } from '@pictor-sdk/video-core'
2
+ import NativePictorVideoModule from './NativePictorVideo'
3
+
4
+ /**
5
+ * Saving a finished export into Android's video collection.
6
+ *
7
+ * A thin boundary. The insert-pending / stream / verify / publish sequence is
8
+ * native, because it is where the file descriptors and the cancellation live;
9
+ * this turns one JSON result into either a {@link SavedMedia} or a typed error,
10
+ * and gives every save an id so a cancel reaches the right one.
11
+ *
12
+ * ## What a failure guarantees
13
+ *
14
+ * Nothing partial. Every unsuccessful native path — a short write, a full disk,
15
+ * a cancellation, an unreadable result — deletes the pending row before
16
+ * returning, and a pending row was never visible to the gallery in the first
17
+ * place. So a failed save leaves the collection exactly as it was.
18
+ *
19
+ * ## What it never does
20
+ *
21
+ * Delete a published item. Releasing the export's temporary is a separate act
22
+ * on a separate file, and a saved video belongs to the user from the moment it
23
+ * is published.
24
+ *
25
+ * @experimental
26
+ */
27
+
28
+ /** Why a save did not happen. @experimental */
29
+ export class MediaSaveFailed extends Error {
30
+ /** Safe to display: never a path, a stack, or a platform message. */
31
+ readonly reason: string
32
+ constructor(reason: string) {
33
+ super(`save failed: ${reason}`)
34
+ this.name = 'MediaSaveFailed'
35
+ this.reason = reason
36
+ }
37
+ }
38
+
39
+ /** A save the user asked to stop. @experimental */
40
+ export class MediaSaveCancelled extends Error {
41
+ constructor() {
42
+ super('save cancelled')
43
+ this.name = 'MediaSaveCancelled'
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Offer a saved video to the system share sheet.
49
+ *
50
+ * Only after a successful save, and only ever the durable `content:` uri. The
51
+ * cache path the export produced is app-private: a receiver could not open it,
52
+ * and handing one out is the mistake `FileUriExposedException` exists to catch.
53
+ *
54
+ * Sharing changes nothing. The same row is offered every time — no copy, no
55
+ * rename, no re-publish, and nothing released.
56
+ *
57
+ * Returns `false` when the device has nothing that accepts a video, which is an
58
+ * outcome rather than a failure: the saved video is untouched either way.
59
+ *
60
+ * @experimental
61
+ */
62
+ export function shareSavedMedia(uri: string): { ok: boolean; noTargets: boolean; reason: string } {
63
+ if (!NativePictorVideoModule) {
64
+ return { ok: false, noTargets: false, reason: 'sharing is not available' }
65
+ }
66
+ if (!uri.startsWith('content://')) {
67
+ /*
68
+ Refused rather than attempted.
69
+
70
+ The only uri worth sharing is the published one. A cache path reaching
71
+ here would mean the save had not happened, and offering it would either
72
+ throw on a modern target or hand a receiver something it cannot read.
73
+ */
74
+ return { ok: false, noTargets: false, reason: 'only a saved video can be shared' }
75
+ }
76
+ const result = JSON.parse(NativePictorVideoModule.mediaShare(uri)) as {
77
+ ok?: boolean
78
+ noTargets?: boolean
79
+ error?: string
80
+ }
81
+ return {
82
+ ok: result.ok === true,
83
+ noTargets: result.noTargets === true,
84
+ reason: result.error ?? '',
85
+ }
86
+ }
87
+
88
+ let counter = 0
89
+
90
+ /** @experimental */
91
+ export function createMediaStoreSaver(): MediaSaver {
92
+ /*
93
+ The job whose cancel is live.
94
+
95
+ Held so a cancel arriving after one save settled cannot stop the next: the
96
+ id is checked natively, and clearing it here means a stray press reaches
97
+ nothing at all.
98
+ */
99
+ let active: string | null = null
100
+
101
+ const native = () => {
102
+ if (!NativePictorVideoModule) {
103
+ throw new MediaSaveFailed('the Pictor video native module is not installed')
104
+ }
105
+ return NativePictorVideoModule
106
+ }
107
+
108
+ return {
109
+ async save(sourceUri: string): Promise<SavedMedia> {
110
+ if (active !== null) {
111
+ // Refused rather than queued: two saves of the same export would insert
112
+ // two rows, and the user asked for one video.
113
+ throw new MediaSaveFailed('a save is already running')
114
+ }
115
+ counter += 1
116
+ const id = `save_${counter.toString(36)}`
117
+ active = id
118
+ let outcome: {
119
+ ok: boolean
120
+ uri?: string
121
+ displayName?: string
122
+ location?: string
123
+ error?: string
124
+ cancelled?: boolean
125
+ }
126
+ try {
127
+ outcome = JSON.parse(await native().mediaSaveAsync(id, sourceUri))
128
+ } finally {
129
+ active = null
130
+ }
131
+ if (outcome.cancelled === true) throw new MediaSaveCancelled()
132
+ if (!outcome.ok || typeof outcome.uri !== 'string') {
133
+ throw new MediaSaveFailed(outcome.error ?? 'the file could not be saved')
134
+ }
135
+ return {
136
+ uri: outcome.uri,
137
+ displayName: outcome.displayName ?? '',
138
+ location: outcome.location ?? '',
139
+ }
140
+ },
141
+
142
+ cancel(): void {
143
+ if (active === null) return
144
+ native().mediaSaveCancel(active)
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Delete pending rows a killed process left behind.
151
+ *
152
+ * Worth calling once at startup. A pending row is invisible to the gallery and
153
+ * still occupies storage, so nothing will ever prompt a user to clean one up.
154
+ *
155
+ * Scoped to this app's own still-pending rows under its own folder: a published
156
+ * item is never a candidate, and neither is anything another app wrote.
157
+ *
158
+ * @experimental
159
+ */
160
+ export function sweepPendingMediaSaves(): {
161
+ swept: number
162
+ inspected: number
163
+ /** Pending rows seen and deliberately left alone. */
164
+ skipped: number
165
+ } {
166
+ if (!NativePictorVideoModule) return { swept: 0, inspected: 0, skipped: 0 }
167
+ const result = JSON.parse(NativePictorVideoModule.mediaSaveSweepPending()) as {
168
+ swept?: number
169
+ inspected?: number
170
+ skipped?: number
171
+ }
172
+ return {
173
+ swept: result.swept ?? 0,
174
+ inspected: result.inspected ?? 0,
175
+ skipped: result.skipped ?? 0,
176
+ }
177
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A debug safety cutoff on native memory.
3
+ *
4
+ * **Remove before production.** This exists because an earlier revision drove
5
+ * the native heap to 1.5 GB in sixty seconds and the process was killed —
6
+ * taking other applications on the device with it, since Android reclaims from
7
+ * everything before it gives up on the foreground app. A diagnostic run must
8
+ * not be able to do that again.
9
+ *
10
+ * Two independent triggers, because a leak can be fast or merely relentless:
11
+ *
12
+ * - **Ceiling.** An absolute limit, well under anything that would put the
13
+ * device under pressure.
14
+ * - **Sustained rise.** Memory that climbs across every sample of a window with
15
+ * no plateau, which is what a slow leak looks like and what a ceiling alone
16
+ * would take minutes to catch.
17
+ *
18
+ * Pure, so the rule is tested rather than trusted: a cutoff that misfires stops
19
+ * a good run, and one that never fires is not a cutoff.
20
+ *
21
+ * @experimental
22
+ */
23
+
24
+ export interface MemoryGuard {
25
+ readonly ceilingBytes: number
26
+ readonly windowSamples: number
27
+ /** Minimum total climb across the window to count as a rise, in bytes. */
28
+ readonly riseBytes: number
29
+ samples: number[]
30
+ tripped: null | 'ceiling' | 'sustained-rise'
31
+ }
32
+
33
+ /**
34
+ * 250 MB. Healthy single-clip playback plateaus near 65 MB.
35
+ *
36
+ * Was 400 MB, which a 1 MB/s leak takes six minutes to reach — long enough for
37
+ * a five-minute run to finish, look successful, and leave the process fat
38
+ * enough that the next run pushed the device into reclaiming from other
39
+ * applications.
40
+ */
41
+ export const DEFAULT_CEILING_BYTES = 250 * 1024 * 1024
42
+
43
+ /** Forty samples at 4 Hz — ten seconds of uninterrupted climbing. */
44
+ export const DEFAULT_WINDOW_SAMPLES = 40
45
+
46
+ /**
47
+ * 8 MB across the window: ~0.8 MB/s.
48
+ *
49
+ * Was 24 MB over three seconds — 8 MB/s, calibrated against a leak that hit
50
+ * 1.5 GB in a minute. It was blind to the 1.07 MB/s leak that actually got the
51
+ * process killed, so the cutoff never fired. A cutoff tuned only to the last
52
+ * failure is not a cutoff.
53
+ *
54
+ * A threshold this small is safe only because the rise must also be *strictly*
55
+ * increasing at every one of the forty samples. Real memory jitters; a leak
56
+ * does not.
57
+ */
58
+ export const DEFAULT_RISE_BYTES = 8 * 1024 * 1024
59
+
60
+ export function createMemoryGuard(
61
+ ceilingBytes = DEFAULT_CEILING_BYTES,
62
+ windowSamples = DEFAULT_WINDOW_SAMPLES,
63
+ riseBytes = DEFAULT_RISE_BYTES,
64
+ ): MemoryGuard {
65
+ return {
66
+ ceilingBytes,
67
+ windowSamples: Math.max(3, Math.floor(windowSamples)),
68
+ riseBytes,
69
+ samples: [],
70
+ tripped: null,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Record a reading and decide whether to stop.
76
+ *
77
+ * Once tripped it stays tripped: the caller has already torn the session down,
78
+ * and a guard that untrips would invite it to start again into the same leak.
79
+ */
80
+ export function observeMemory(guard: MemoryGuard, bytes: number): MemoryGuard {
81
+ if (guard.tripped) return guard
82
+ if (!Number.isFinite(bytes) || bytes <= 0) return guard
83
+
84
+ if (bytes >= guard.ceilingBytes) {
85
+ return { ...guard, samples: [...guard.samples, bytes].slice(-guard.windowSamples), tripped: 'ceiling' }
86
+ }
87
+
88
+ const samples = [...guard.samples, bytes].slice(-guard.windowSamples)
89
+ if (samples.length < guard.windowSamples) return { ...guard, samples }
90
+
91
+ // **Strictly** increasing across the whole window, and a meaningful total
92
+ // climb. Both conditions matter, and strictness is the subtle one: allowing
93
+ // equal consecutive samples made a single large allocation followed by a flat
94
+ // plateau — a decoder starting up — read as a leak. A leak climbs at every
95
+ // sample; an allocation climbs once and then stops.
96
+ let climbing = true
97
+ for (let i = 1; i < samples.length; i++) {
98
+ if (samples[i]! <= samples[i - 1]!) {
99
+ climbing = false
100
+ break
101
+ }
102
+ }
103
+ const climb = samples[samples.length - 1]! - samples[0]!
104
+ if (climbing && climb >= guard.riseBytes) {
105
+ return { ...guard, samples, tripped: 'sustained-rise' }
106
+ }
107
+ return { ...guard, samples }
108
+ }
109
+
110
+ /** A sentence a human can act on, or null while nothing is wrong. */
111
+ export function describeTrip(guard: MemoryGuard): string | null {
112
+ if (!guard.tripped) return null
113
+ const mb = (b: number) => (b / (1024 * 1024)).toFixed(0)
114
+ const latest = guard.samples[guard.samples.length - 1] ?? 0
115
+ return guard.tripped === 'ceiling'
116
+ ? `native heap reached ${mb(latest)} MB (ceiling ${mb(guard.ceilingBytes)} MB) — session released`
117
+ : `native heap rose ${mb(latest - (guard.samples[0] ?? 0))} MB across ${guard.samples.length} samples without plateauing — session released`
118
+ }