@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/index.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `@pictor-sdk/video-android` — optional Android video playback adapter.
3
+ *
4
+ * Playback is Media3's ExoPlayer rendering into a persistent external-OES
5
+ * `SurfaceTexture`; the renderer keeps one texture and one `SkImage` for the
6
+ * whole session and composes them with the rest of the document in Skia. No
7
+ * frame crosses the JS thread.
8
+ *
9
+ * **Requires Android API 29 or higher**, which is conservative: the surviving
10
+ * path needs 24, and 29 is held only until something older than the one device
11
+ * here can actually run it. Installing this package raises a host's
12
+ * `minSdkVersion`; not installing it leaves image-only consumers exactly where
13
+ * they were.
14
+ *
15
+ * The surface here is **internal** until the Pixel 7 measurements validate it.
16
+ * Nothing is re-exported from a package a consumer installs by default.
17
+ *
18
+ * @experimental
19
+ */
20
+ export {
21
+ createMemoryGuard,
22
+ describeTrip,
23
+ observeMemory,
24
+ DEFAULT_CEILING_BYTES,
25
+ DEFAULT_RISE_BYTES,
26
+ DEFAULT_WINDOW_SAMPLES,
27
+ type MemoryGuard,
28
+ } from './memoryGuard'
29
+
30
+ export {
31
+ createSyncTrace,
32
+ distribution,
33
+ expectedPtsAt,
34
+ record as recordTick,
35
+ syncReport,
36
+ type Distribution,
37
+ type SyncReport,
38
+ type SyncTrace,
39
+ type TickSample,
40
+ } from './syncTrace'
41
+
42
+ export { AndroidVideoPlayer } from './player'
43
+
44
+ export { Media3, type Media3State } from './media3'
45
+
46
+ export {
47
+ NO_SURFACE,
48
+ afterPrepare,
49
+ idlePlaylistSession,
50
+ isCurrentGeneration,
51
+ retireSurface,
52
+ shouldPrepare,
53
+ type PlaylistIntent,
54
+ type PlaylistSession,
55
+ } from './playlistSession'
56
+
57
+ export {
58
+ createSurfaceOwner,
59
+ nextOwnerToken,
60
+ resetSurfaceOwnerForTests,
61
+ surfaceOwner,
62
+ type SurfaceCreateResult,
63
+ type SurfaceOwner,
64
+ type SurfaceOwnerEffects,
65
+ type SurfaceReadiness,
66
+ } from './surfaceOwner'
67
+
68
+ export {
69
+ useVideoSurface,
70
+ videoSurfaceSlot,
71
+ type VideoSurface,
72
+ type VideoSurfaceHandle,
73
+ type VideoSurfaceOptions,
74
+ } from './useVideoSurface'
75
+
76
+ export {
77
+ useCompositionPlayback,
78
+ type CompositionPlayback,
79
+ type CompositionPlaybackState,
80
+ type UseCompositionPlaybackOptions,
81
+ } from './useCompositionPlayback'
82
+
83
+ export {
84
+ useMedia3Playback,
85
+ type Media3Playback,
86
+ type UseMedia3PlaybackOptions,
87
+ } from './useMedia3Playback'
88
+
89
+ export {
90
+ createAndroidThumbnailProvider,
91
+ thumbnailCounters,
92
+ type AndroidThumbnailOptions,
93
+ } from './thumbnailProvider'
94
+
95
+ export {
96
+ beginPhase,
97
+ observe,
98
+ recoveryFrames,
99
+ CONVERGED_RUN,
100
+ MAX_RECOVERY_SAMPLES,
101
+ type Phase,
102
+ type PhaseState,
103
+ } from './convergence'
104
+
105
+ export {
106
+ createMedia3VideoExporter,
107
+ VideoExportRejected,
108
+ type Media3ExporterOptions,
109
+ type OverlayPreparationResult,
110
+ type OverlayResourceDescriptor,
111
+ } from './exporter'
112
+
113
+ export {
114
+ createPlaybackLifecycle,
115
+ type PlaybackLifecycle,
116
+ type PlaybackLifecycleEffects,
117
+ type PlaybackStopReason,
118
+ } from './playbackLifecycle'
119
+ export {
120
+ RECOVERY_GRACE_MS,
121
+ classifyStall,
122
+ shouldRecover,
123
+ type RecoverySample,
124
+ type StallKind,
125
+ } from './surfaceRecovery'
126
+
127
+ export {
128
+ EXPORT_STAGES,
129
+ createExportJobs,
130
+ type ExportJob,
131
+ type ExportJobs,
132
+ type ExportJobsOptions,
133
+ type ExportStage,
134
+ } from './exportJob'
135
+
136
+ export {
137
+ MAX_OBSERVED_HEARTBEAT_GAP_MS,
138
+ STALL_GRACE_MS,
139
+ createProgressReporter,
140
+ createStallWatchdog,
141
+ planProgressWeights,
142
+ type ProgressPlan,
143
+ type ProgressPlanInput,
144
+ type ProgressReporter,
145
+ type ProgressReporterOptions,
146
+ type ShardPlan,
147
+ type StallVerdict,
148
+ type StallWatchdog,
149
+ type StallWatchdogOptions,
150
+ } from './exportProgress'
151
+
152
+ export {
153
+ createMediaStoreSaver,
154
+ shareSavedMedia,
155
+ sweepPendingMediaSaves,
156
+ MediaSaveCancelled,
157
+ MediaSaveFailed,
158
+ } from './mediaStore'
159
+
160
+ export {
161
+ createOverlayStagingSink,
162
+ installOverlayStaging,
163
+ measureStagingStall,
164
+ sweepOverlayStaging,
165
+ type OverlayStagingSink,
166
+ type StagedRaster,
167
+ } from './overlayStaging'
168
+ export { probeAudioSource } from './audioProbe'
169
+ export type { AudioProbeFailure, AudioProbeResult } from './audioProbe'
170
+ export {
171
+ ingestManagedSource,
172
+ managedSourceLedger,
173
+ managedSourceList,
174
+ seedManagedSourceForGate,
175
+ sweepManagedSourcesRawForGate,
176
+ managedSourceToken,
177
+ releaseManagedSource,
178
+ sweepManagedSources,
179
+ } from './managedSources'
180
+ export type { IngestResult, ManagedSource, ManagedSourceFailure } from './managedSources'
181
+ export {
182
+ cancelPreviewMix,
183
+ preparePreviewMix,
184
+ previewMixKey,
185
+ previewMixLedger,
186
+ releasePreviewMix,
187
+ sweepPreviewMixes,
188
+ } from './previewMix'
189
+ export type { PreparedMix, PreviewMixResult } from './previewMix'
190
+ export {
191
+ PREVIEW_DRIFT_TOLERANCE_US,
192
+ decidePreviewAudio,
193
+ previewAudioNeeded,
194
+ previewProgramJson,
195
+ } from './previewAudioSession'
196
+ export type {
197
+ PreviewAudioDecision,
198
+ PreviewAudioInputs,
199
+ PreviewAudioStatus,
200
+ } from './previewAudioSession'
201
+ export {
202
+ PHASE_MARK_PREFIX,
203
+ summarisePhases,
204
+ type PhaseReport,
205
+ type PhaseSummary,
206
+ } from './tracePhases'
207
+ export {
208
+ compositionSpikeSpec,
209
+ spikePause,
210
+ spikePlay,
211
+ spikeRelease,
212
+ spikeSeek,
213
+ spikeClaim,
214
+ spikeDrain,
215
+ spikeStart,
216
+ spikeState,
217
+ type CompositionSpikeItem,
218
+ type CompositionSpikeSpec,
219
+ type CompositionSpikeState,
220
+ type SpikeSample,
221
+ } from './compositionSpike'
222
+ export {
223
+ classifyExcursions,
224
+ offsetAt,
225
+ windowAround,
226
+ SPIKE_TOLERANCE_US,
227
+ type Excursion,
228
+ type ExcursionCause,
229
+ type ExcursionReport,
230
+ } from './spikeExcursions'
231
+ export {
232
+ PREVIEW_OUTPUT_FPS,
233
+ PREVIEW_TOLERANCE_US,
234
+ checkCadence,
235
+ type CadenceClip,
236
+ type CadenceResult,
237
+ type Verdict,
238
+ } from './previewCadence'
239
+ export {
240
+ scoreSubmissions,
241
+ traceSeam,
242
+ traceStartup,
243
+ traceSubmission,
244
+ type ScoredSubmission,
245
+ type SubmissionRecord,
246
+ type SubmissionReport,
247
+ type SeamStage,
248
+ type StartupRow,
249
+ type StartupTrace,
250
+ type SeamTrace,
251
+ type SubmissionTrace,
252
+ type TraceRow,
253
+ } from './submissionScoring'
254
+ export { usePreviewAudio } from './usePreviewAudio'
255
+ export { previewAudioDiagnostics } from './previewAudioDiagnostics'
256
+ export type { PreviewAudioDiagnostics } from './previewAudioDiagnostics'
257
+ export type { PreviewAudioBinding, UsePreviewAudioOptions } from './usePreviewAudio'
258
+ export { mixSpanContext, resolveMixSources } from './mixSources'
259
+ export type { ResolvedMixSources } from './mixSources'
260
+ export { ENCODER_GATE_FIXTURES, runEncoderGate } from './encoderGate'
261
+ export type { EncoderGateResult } from './encoderGate'
262
+ export {
263
+ armPreviewTrace,
264
+ drainAllPreviewTrace,
265
+ drainPreviewTrace,
266
+ markPreviewTrace,
267
+ muteSourceAudio,
268
+ previewCodecLedger,
269
+ previewFramePipeline,
270
+ previewParts,
271
+ setPreviewParts,
272
+ previewSyncCounters,
273
+ stopPreviewForHost,
274
+ summariseOffsets,
275
+ } from './previewTrace'
276
+ export type { PreviewParts, TraceEvent, TracePage } from './previewTrace'
@@ -0,0 +1,252 @@
1
+ import { requireNative } from './player'
2
+
3
+ /**
4
+ * Media the app owns, copied out of wherever it was picked from.
5
+ *
6
+ * A picker returns a reference, not a file. A `content://` grant is scoped to
7
+ * the activity result that produced it; `copyToCacheDirectory` improves on that
8
+ * by writing into **cache**, which Android reclaims whenever it wants storage
9
+ * back. Either way a project persisted with a picker URI reopens with media
10
+ * that has quietly gone, and nothing says so until an export fails.
11
+ *
12
+ * So ingestion copies into `filesDir` and the project stores that. See
13
+ * `ManagedSources.kt` for why the name is the content hash and why nothing is
14
+ * ever deleted implicitly.
15
+ *
16
+ * @experimental
17
+ */
18
+
19
+ /** Where a managed source lives, and what it is. @experimental */
20
+ export interface ManagedSource {
21
+ /** A `file://` URI in app-owned durable storage. Safe to persist. */
22
+ uri: string
23
+ /** The content hash. Names the file; never derived from the user's filename. */
24
+ token: string
25
+ byteLength: number
26
+ sha256: string
27
+ /**
28
+ * Whether this call brought the file into existence.
29
+ *
30
+ * False means the bytes were already stored and something else may be
31
+ * pointing at them — a caller unwinding its own work must not release a
32
+ * source it did not create.
33
+ */
34
+ created: boolean
35
+ }
36
+
37
+ /** @experimental */
38
+ export interface ManagedSourceFailure {
39
+ ok: false
40
+ /** Diagnostic. Never shown to a person: it may name a path. */
41
+ reason: string
42
+ }
43
+
44
+ /** @experimental */
45
+ export type IngestResult = ({ ok: true } & ManagedSource) | ManagedSourceFailure
46
+
47
+ /**
48
+ * Copy a picked source into durable, app-owned storage.
49
+ *
50
+ * Never throws for an unreadable source: a pick that cannot be copied is an
51
+ * answer. It throws only if the adapter is not installed.
52
+ *
53
+ * @experimental
54
+ */
55
+ export function ingestManagedSource(uri: string): IngestResult {
56
+ return parse<IngestResult>(
57
+ () => requireNative().ingestManagedSource(uri),
58
+ (value) => ({
59
+ ok: true,
60
+ uri: String(value.uri ?? ''),
61
+ token: String(value.token ?? ''),
62
+ byteLength: Number(value.byteLength ?? 0),
63
+ sha256: String(value.sha256 ?? ''),
64
+ created: value.created === true,
65
+ }),
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Drop one managed source.
71
+ *
72
+ * For unwinding an ingestion whose item was never created. Not for Undo: an
73
+ * Undo that deleted the source would leave Redo restoring an item that points
74
+ * at nothing.
75
+ *
76
+ * @experimental
77
+ */
78
+ export function releaseManagedSource(token: string): boolean {
79
+ try {
80
+ const value = JSON.parse(requireNative().releaseManagedSource(token)) as { removed?: boolean }
81
+ return value.removed === true
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Remove every managed source not named in `keepTokens`.
89
+ *
90
+ * The only thing that deletes media, and it is always given the full set of
91
+ * what the project still references. Also removes partials a killed process
92
+ * left behind.
93
+ *
94
+ * @experimental
95
+ */
96
+ export function sweepManagedSources(keepTokens: readonly string[]): {
97
+ removed: number
98
+ kept: number
99
+ partials: number
100
+ /** Files that are not ours, counted so the accounting matches a listing. */
101
+ skipped: number
102
+ } {
103
+ try {
104
+ const value = JSON.parse(
105
+ requireNative().sweepManagedSources(JSON.stringify([...keepTokens])),
106
+ ) as { ok?: boolean; removed?: number; kept?: number; partials?: number; skipped?: number }
107
+ if (value.ok !== true) return { removed: 0, kept: 0, partials: 0, skipped: 0 }
108
+ return {
109
+ removed: Number(value.removed ?? 0),
110
+ kept: Number(value.kept ?? 0),
111
+ partials: Number(value.partials ?? 0),
112
+ skipped: Number(value.skipped ?? 0),
113
+ }
114
+ } catch {
115
+ // A sweep that cannot be understood removes nothing. The opposite default
116
+ // would delete a project's media on a parse error.
117
+ return { removed: 0, kept: 0, partials: 0, skipped: 0 }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Every filename in the managed directory, including partials and strays.
123
+ *
124
+ * Read-only accounting, so it is available in release for the same reason the
125
+ * ledgers are: it is how a sweep is proven rather than asserted.
126
+ * {@link managedSourceLedger} reports only well-formed sources, which cannot
127
+ * show what a sweep left behind.
128
+ *
129
+ * @experimental
130
+ */
131
+ export function managedSourceList(): { name: string; byteLength: number }[] {
132
+ try {
133
+ const value = JSON.parse(requireNative().managedSourceList()) as {
134
+ items?: { name: string; byteLength: number }[]
135
+ }
136
+ return value.items ?? []
137
+ } catch {
138
+ return []
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Gate-only: put a file in the managed directory with an exact name.
144
+ *
145
+ * Ingestion only ever produces well-formed, referenced sources, so the cases a
146
+ * sweep exists to handle — an orphan, a stale partial, a file that is not ours
147
+ * — cannot be reached through the normal API. Refused in a release build built
148
+ * without diagnostics.
149
+ *
150
+ * @experimental
151
+ */
152
+ export function seedManagedSourceForGate(name: string, bytes: number): boolean {
153
+ try {
154
+ const value = JSON.parse(requireNative().managedSourceGateSeed(name, bytes)) as { ok?: boolean }
155
+ return value.ok === true
156
+ } catch {
157
+ return false
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Gate-only: sweep with a keep list handed over exactly as written.
163
+ *
164
+ * The production path serialises its own list, so an unparseable one can never
165
+ * reach native — which makes "an unreadable keep list deletes nothing"
166
+ * unprovable through it. Returns what native reported.
167
+ *
168
+ * @experimental
169
+ */
170
+ export function sweepManagedSourcesRawForGate(rawKeep: string): {
171
+ ok: boolean
172
+ removed: number
173
+ kept: number
174
+ partials: number
175
+ skipped: number
176
+ } {
177
+ try {
178
+ const value = JSON.parse(requireNative().managedSourceGateSweepRaw(rawKeep)) as {
179
+ ok?: boolean
180
+ removed?: number
181
+ kept?: number
182
+ partials?: number
183
+ skipped?: number
184
+ }
185
+ return {
186
+ ok: value.ok === true,
187
+ removed: Number(value.removed ?? 0),
188
+ kept: Number(value.kept ?? 0),
189
+ partials: Number(value.partials ?? 0),
190
+ skipped: Number(value.skipped ?? 0),
191
+ }
192
+ } catch {
193
+ return { ok: false, removed: 0, kept: 0, partials: 0, skipped: 0 }
194
+ }
195
+ }
196
+
197
+ /** What is stored, for assertions. Tokens and sizes, never names. @experimental */
198
+ export function managedSourceLedger(): {
199
+ count: number
200
+ bytes: number
201
+ items: { token: string; byteLength: number }[]
202
+ } {
203
+ try {
204
+ const value = JSON.parse(requireNative().managedSourceLedger()) as {
205
+ count?: number
206
+ bytes?: number
207
+ items?: { token: string; byteLength: number }[]
208
+ }
209
+ return {
210
+ count: Number(value.count ?? 0),
211
+ bytes: Number(value.bytes ?? 0),
212
+ items: value.items ?? [],
213
+ }
214
+ } catch {
215
+ return { count: 0, bytes: 0, items: [] }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The token a managed URI names, or null if it is not one of ours.
221
+ *
222
+ * Lets a host collect the keep list for {@link sweepManagedSources} straight
223
+ * from a project's sources without storing a second index that could drift.
224
+ *
225
+ * @experimental
226
+ */
227
+ export function managedSourceToken(uri: string): string | null {
228
+ const match = /\/([0-9a-f]{64})\.bin$/.exec(uri)
229
+ return match ? match[1]! : null
230
+ }
231
+
232
+ function parse<T>(
233
+ call: () => string,
234
+ build: (value: Record<string, unknown>) => T,
235
+ ): T | ManagedSourceFailure {
236
+ let raw: string
237
+ try {
238
+ raw = call()
239
+ } catch (error) {
240
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
241
+ }
242
+ let value: Record<string, unknown>
243
+ try {
244
+ value = JSON.parse(raw) as Record<string, unknown>
245
+ } catch {
246
+ return { ok: false, reason: 'native returned unparseable JSON' }
247
+ }
248
+ if (value.ok !== true) {
249
+ return { ok: false, reason: typeof value.reason === 'string' ? value.reason : 'failed' }
250
+ }
251
+ return build(value)
252
+ }