@pictor-sdk/video-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,305 @@
1
+ import { playlistOf, type VideoProject } from '@pictor-sdk/video-core'
2
+ import { requireNative } from './player'
3
+
4
+ /**
5
+ * The shared-clock playback spike, from the host's side.
6
+ *
7
+ * ## What it is testing
8
+ *
9
+ * Whether one playback-clock authority can drive the picture and a prepared,
10
+ * project-wide mix at once. The two-player preview cannot meet the 60 ms gate
11
+ * and the reasons are structural: anchors polled at 50 ms make a single
12
+ * comparison uncertain by up to ±56 ms against a 60 ms tolerance, the
13
+ * correction loop runs at 500 ms while each corrective seek costs 200-400 ms,
14
+ * and at a seam the project clock divides by a speed the player applies about
15
+ * 450 ms later.
16
+ *
17
+ * ## Why the spec is built here
18
+ *
19
+ * `playlistOf` is the shared compiler, and it already decides every number this
20
+ * needs: the source bounds a clip plays between, its speed, and where the
21
+ * window sits on the project timeline. Passing its output through verbatim is
22
+ * what keeps the adapter free of a second interpretation of the document —
23
+ * native maps items onto media items and nothing else.
24
+ *
25
+ * Diagnostics-only, and it says nothing about live PCM gain or fades: it plays
26
+ * the same prepared mix file the current preview plays.
27
+ *
28
+ * @experimental
29
+ */
30
+
31
+ /** One clip, as the compiler described it. @experimental */
32
+ export interface CompositionSpikeItem {
33
+ uri: string
34
+ /** Clipping bounds in **source** time, which is where speed is applied. */
35
+ startUs: number
36
+ endUs: number
37
+ speed: number
38
+ }
39
+
40
+ /** @experimental */
41
+ export interface CompositionSpikeSpec {
42
+ /**
43
+ * Hold the shared clock at each clip boundary.
44
+ *
45
+ * Off, and measured off for a reason: with `playWhenReady` false
46
+ * `CompositionPlayer` does not advance across an item boundary, so the
47
+ * incoming frame the hold waits for is never produced and the hold runs to
48
+ * its bound. Holding the clock and preparing the next stream are mutually
49
+ * exclusive on this player.
50
+ */
51
+ seamHold?: boolean
52
+ /**
53
+ * Prepare the incoming item with an explicit seek while the clock is held.
54
+ *
55
+ * Only meaningful with `seamHold`. Pausing natural traversal and seeking to a
56
+ * target are different operations: the seek path resets the renderers, which
57
+ * is what makes a paused player produce a frame, and is the same path the
58
+ * existing seek readiness already passes on.
59
+ */
60
+ seamSeek?: boolean
61
+ items: CompositionSpikeItem[]
62
+ /** The prepared mix. Empty plays the picture with no sound at all. */
63
+ mixUri: string
64
+ /**
65
+ * The mix's duration.
66
+ *
67
+ * Required whenever `mixUri` is set: the player computes each sequence's
68
+ * duration before playing, and an audio-only item cannot report one itself.
69
+ */
70
+ mixDurationUs: number
71
+ width: number
72
+ height: number
73
+ /**
74
+ * The request this attachment belongs to.
75
+ *
76
+ * Claimed before preparation starts. Native refuses an attachment whose id is
77
+ * older than the newest claim, so a preparation that finishes late cannot put
78
+ * a stale mix on screen for even one frame. Omit for runs with no race.
79
+ */
80
+ requestId?: number
81
+ }
82
+
83
+ /** @experimental */
84
+ export interface CompositionSpikeState {
85
+ ok: boolean
86
+ attached: boolean
87
+ built: number
88
+ released: number
89
+ error: string | null
90
+ /**
91
+ * Readiness latency, reported apart from synchronisation.
92
+ *
93
+ * A preparation pause is a latency figure and an A/V offset is an error;
94
+ * averaging one into the other is how a slow start reads as drift.
95
+ */
96
+ startupMs: number
97
+ seekMs: number
98
+ /** What the person asked for, independent of whether the transport is running. */
99
+ intentPlaying?: boolean
100
+ /** A hold waiting for the first frame of the current epoch. */
101
+ releasePending?: boolean
102
+ releases?: number
103
+ readinessTimeouts?: number
104
+ readinessFailure?: string | null
105
+ /** How long the last hold lasted before playback was released. */
106
+ holdMs?: number
107
+ /**
108
+ * Wall-clock milliseconds spent holding the clock at each clip boundary.
109
+ *
110
+ * Listed, never averaged. Each entry is a pause the person sees: the decoder
111
+ * is torn down and rebuilt at a boundary and the clock waits rather than the
112
+ * picture falling behind it. **This is not seamless playback**, which is why
113
+ * it is reported apart from every synchronisation figure.
114
+ */
115
+ seamHoldMs?: number[]
116
+ /** Holds that reached their bound without a correctly associated frame. */
117
+ seamHoldTimeouts?: number
118
+ seamHoldActive?: boolean
119
+ atNs?: number
120
+ positionUs?: number
121
+ playing?: boolean
122
+ playbackState?: number
123
+ durationUs?: number
124
+ presentedUs?: number | null
125
+ /** How old the presented frame was when it was read, so a stalled sample can be excluded. */
126
+ presentedAgeNs?: number
127
+ /** How long the paired read itself took. Bounds the pairing error. */
128
+ readSpanNs?: number
129
+ /** How stale the pairing was when it was read out. */
130
+ pairingAgeNs?: number
131
+ }
132
+
133
+ /**
134
+ * Build the spike's spec from a project and a prepared mix.
135
+ *
136
+ * Unsupported clips are not silently dropped: `playlistOf` reports them, and a
137
+ * spec built from a playlist missing a window would put every later seam in the
138
+ * wrong place — which is exactly the class of error this spike exists to
139
+ * measure.
140
+ *
141
+ * @experimental
142
+ */
143
+ export function compositionSpikeSpec(
144
+ project: VideoProject,
145
+ mix: { uri: string; durationUs: number },
146
+ resolve: (source: { kind: string; uri?: string }) => string | null,
147
+ ): { spec: CompositionSpikeSpec; unsupported: string[] } {
148
+ const playlist = playlistOf(project)
149
+ const items: CompositionSpikeItem[] = []
150
+ const unsupported = playlist.unsupported.map((u) => `${u.clipId}: ${u.reason}`)
151
+ for (const item of playlist.items) {
152
+ const uri = resolve(item.source as unknown as { kind: string; uri?: string })
153
+ if (uri === null) {
154
+ unsupported.push(`${item.clipId}: unresolved`)
155
+ continue
156
+ }
157
+ items.push({
158
+ uri,
159
+ startUs: Number(item.startUs),
160
+ endUs: Number(item.endUs),
161
+ speed: item.speed,
162
+ })
163
+ }
164
+ return {
165
+ spec: {
166
+ items,
167
+ mixUri: mix.uri,
168
+ mixDurationUs: mix.durationUs,
169
+ width: project.size.width,
170
+ height: project.size.height,
171
+ },
172
+ unsupported,
173
+ }
174
+ }
175
+
176
+ function call(fn: () => string, label: string): Record<string, unknown> {
177
+ try {
178
+ return JSON.parse(fn()) as Record<string, unknown>
179
+ } catch (error) {
180
+ return { ok: false, error: `${label}: ${String(error)}` }
181
+ }
182
+ }
183
+
184
+ /** Claim the next request id, before preparing. @experimental */
185
+ export function spikeClaim(): number {
186
+ const result = call(() => requireNative().spikeClaim(), 'spikeClaim')
187
+ return typeof result.requestId === 'number' ? result.requestId : 0
188
+ }
189
+
190
+ /** @experimental */
191
+ export function spikeStart(spec: CompositionSpikeSpec): Record<string, unknown> {
192
+ return call(() => requireNative().spikeStart(JSON.stringify(spec)), 'spikeStart')
193
+ }
194
+
195
+ /** @experimental */
196
+ export function spikePlay(): Record<string, unknown> {
197
+ return call(() => requireNative().spikePlay(), 'spikePlay')
198
+ }
199
+
200
+ /** @experimental */
201
+ export function spikePause(): Record<string, unknown> {
202
+ return call(() => requireNative().spikePause(), 'spikePause')
203
+ }
204
+
205
+ /** @experimental */
206
+ export function spikeSeek(positionUs: number): Record<string, unknown> {
207
+ return call(() => requireNative().spikeSeek(positionUs), 'spikeSeek')
208
+ }
209
+
210
+ /** @experimental */
211
+ export function spikeState(): CompositionSpikeState {
212
+ return call(() => requireNative().spikeState(), 'spikeState') as unknown as CompositionSpikeState
213
+ }
214
+
215
+ /**
216
+ * One retained sample, unpacked.
217
+ *
218
+ * Packed across the boundary because a gate reads thousands of these, and named
219
+ * here because a gate that indexes into an array of eighteen longs is one
220
+ * transposition away from a confident wrong answer.
221
+ *
222
+ * @experimental
223
+ */
224
+ export interface SpikeSample {
225
+ seq: number
226
+ atNs: number
227
+ positionUs: number
228
+ playing: boolean
229
+ ready: boolean
230
+ epoch: number
231
+ sinceEpochNs: number
232
+ frameId: number
233
+ frameGeneration: number
234
+ frameWindow: number
235
+ framePtsUs: number
236
+ /**
237
+ * When Media3's renderer said this frame was about to go to the surface.
238
+ *
239
+ * The earliest boundary observable here, and the one that separates the
240
+ * owners of a gap: nothing before this is the decoder or the item's
241
+ * preparation, this to {@link SpikeSample.frameAvailableNs} is the video
242
+ * graph, and that to {@link SpikeSample.frameConsumedNs} is this consumer.
243
+ */
244
+ frameMetadataNs: number
245
+ /** The time Media3 *asked* the surface to present at. An intention. */
246
+ frameReleaseNs: number
247
+ /** The time the consumer latched it. The last boundary observable here. */
248
+ frameConsumedNs: number
249
+ frameAvailableNs: number
250
+ coalesced: number
251
+ offered: number
252
+ available: number
253
+ taken: number
254
+ readSpanNs: number
255
+ }
256
+
257
+ /** @experimental */
258
+ export function spikeDrain(fromSeq: number, limit = 400): {
259
+ ok: boolean
260
+ rows: SpikeSample[]
261
+ nextSeq: number
262
+ total: number
263
+ dropped: number
264
+ } {
265
+ const page = call(() => requireNative().spikeDrain(fromSeq, limit), 'spikeDrain') as {
266
+ ok?: boolean
267
+ rows?: number[][]
268
+ nextSeq?: number
269
+ total?: number
270
+ dropped?: number
271
+ }
272
+ return {
273
+ ok: page.ok === true,
274
+ rows: (page.rows ?? []).map((r) => ({
275
+ seq: r[0]!,
276
+ atNs: r[1]!,
277
+ positionUs: r[2]!,
278
+ playing: (r[3]! & 1) === 1,
279
+ ready: (r[3]! & 2) === 2,
280
+ epoch: r[4]!,
281
+ sinceEpochNs: r[5]!,
282
+ frameId: r[6]!,
283
+ frameGeneration: r[7]!,
284
+ frameWindow: r[8]!,
285
+ framePtsUs: r[9]!,
286
+ frameReleaseNs: r[10]!,
287
+ frameConsumedNs: r[11]!,
288
+ frameAvailableNs: r[12]!,
289
+ coalesced: r[13]!,
290
+ offered: r[14]!,
291
+ available: r[15]!,
292
+ taken: r[16]!,
293
+ readSpanNs: r[17]!,
294
+ frameMetadataNs: r[18] ?? -1,
295
+ })),
296
+ nextSeq: page.nextSeq ?? fromSeq,
297
+ total: page.total ?? 0,
298
+ dropped: page.dropped ?? 0,
299
+ }
300
+ }
301
+
302
+ /** @experimental */
303
+ export function spikeRelease(): Record<string, unknown> {
304
+ return call(() => requireNative().spikeRelease(), 'spikeRelease')
305
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Deciding when playback has settled, instead of assuming it after a timeout.
3
+ *
4
+ * The first attempt excluded a fixed 1.5 s after every transition. That is a
5
+ * guess in both directions: too short for a seek, whose tail leaked into the
6
+ * steady figures, and too long for a rate change that settles immediately. It
7
+ * also hides the thing worth knowing — how long recovery actually took.
8
+ *
9
+ * So a transition opens a phase, and the phase closes when the offsets say so:
10
+ * [CONVERGED_RUN] consecutive samples inside the expected bound. The recovery
11
+ * duration and the worst offset seen on the way are kept, because a transient
12
+ * that is excluded but not reported is a failure nobody can see.
13
+ *
14
+ * [MAX_RECOVERY_SAMPLES] is a safety net, not a policy. A phase that never
15
+ * converges is closed anyway and marked, so a permanent failure surfaces as a
16
+ * failure instead of an ever-growing exclusion window.
17
+ *
18
+ * Pure, so the rule is tested rather than trusted.
19
+ *
20
+ * @experimental
21
+ */
22
+
23
+ /** What kind of transition opened a phase. Steady is the absence of one. */
24
+ export type Phase =
25
+ | 'steady'
26
+ | 'startup'
27
+ | 'seek'
28
+ | 'source'
29
+ | 'pauseResume'
30
+ | 'rateChange'
31
+
32
+ /**
33
+ * Consecutive in-bound samples that end a transient.
34
+ *
35
+ * Eight at 30 fps is about a quarter second of agreement — long enough that a
36
+ * single lucky frame cannot close a phase, short enough to measure recovery
37
+ * rather than round it up.
38
+ */
39
+ export const CONVERGED_RUN = 8
40
+
41
+ /** A phase is closed after this many samples whatever they say. */
42
+ export const MAX_RECOVERY_SAMPLES = 600
43
+
44
+ export interface PhaseState {
45
+ phase: Phase
46
+ /** Offsets collected while this phase was open. */
47
+ samples: number[]
48
+ /** Consecutive in-bound samples so far. */
49
+ run: number
50
+ /** Worst absolute offset seen during the transient, in microseconds. */
51
+ worstUs: number
52
+ /** Samples taken before it converged. */
53
+ recoverySamples: number
54
+ /** True once converged; further samples belong to steady state. */
55
+ converged: boolean
56
+ /** True when the safety net closed it instead of convergence. */
57
+ timedOut: boolean
58
+ }
59
+
60
+ export function beginPhase(phase: Phase): PhaseState {
61
+ return {
62
+ phase,
63
+ samples: [],
64
+ run: 0,
65
+ worstUs: 0,
66
+ recoverySamples: 0,
67
+ converged: phase === 'steady',
68
+ timedOut: false,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Feed one offset. Returns the updated phase and where the sample belongs.
74
+ *
75
+ * A sample taken while a transient is open belongs to that transient, never to
76
+ * steady state — merging them would report the transitions as if they were
77
+ * playback.
78
+ */
79
+ export function observe(
80
+ state: PhaseState,
81
+ offsetUs: number,
82
+ boundUs: number,
83
+ ): { state: PhaseState; bucket: Phase } {
84
+ if (state.converged) {
85
+ return { state, bucket: 'steady' }
86
+ }
87
+
88
+ const magnitude = Math.abs(offsetUs)
89
+ const next: PhaseState = {
90
+ ...state,
91
+ samples: [...state.samples, offsetUs],
92
+ recoverySamples: state.recoverySamples + 1,
93
+ worstUs: Math.max(state.worstUs, magnitude),
94
+ run: magnitude <= boundUs ? state.run + 1 : 0,
95
+ }
96
+
97
+ if (next.run >= CONVERGED_RUN) {
98
+ next.converged = true
99
+ } else if (next.recoverySamples >= MAX_RECOVERY_SAMPLES) {
100
+ // Closed by the safety net. Marked, so it reads as a failure to converge
101
+ // rather than quietly becoming steady state.
102
+ next.converged = true
103
+ next.timedOut = true
104
+ }
105
+
106
+ return { state: next, bucket: state.phase }
107
+ }
108
+
109
+ /** How long recovery took, in frames, or null while still recovering. */
110
+ export function recoveryFrames(state: PhaseState): number | null {
111
+ return state.converged ? state.recoverySamples : null
112
+ }
@@ -0,0 +1,66 @@
1
+ import { requireNative } from './player'
2
+
3
+ /**
4
+ * The AAC encoder's frozen behaviour, re-measurable on demand.
5
+ *
6
+ * The encode loop is 16% of a preview preparation and was 89% of it before it
7
+ * was rewritten. That rewrite was checked against a baseline of natively
8
+ * generated PCM fixtures — inputs that are formulas rather than files, so the
9
+ * two halves of the comparison cannot drift apart — and the check was exact:
10
+ * access-unit count, payload bytes, payload hash, CSD, timestamps, decoded
11
+ * frame count, RMS and transient positions all matched.
12
+ *
13
+ * This is the door to that. Diagnostics-gated in native, so a release build
14
+ * without them refuses.
15
+ *
16
+ * @experimental
17
+ */
18
+
19
+ /** @experimental */
20
+ export interface EncoderGateResult {
21
+ ok: boolean
22
+ fixture?: string
23
+ /** Compressed shape. */
24
+ units?: number
25
+ payloadBytes?: number
26
+ payloadHash?: number
27
+ csd0?: number
28
+ firstUs?: number
29
+ lastUs?: number
30
+ headTimestamps?: number[]
31
+ /** Decoded result. */
32
+ decodedFrames?: number
33
+ decodedRms?: number
34
+ peak?: number
35
+ markerPositionsMs?: number[]
36
+ expectedMarkersMs?: number[]
37
+ /** What it cost. */
38
+ encodeMs?: number
39
+ inputFrames?: number
40
+ fileBytes?: number
41
+ error?: string
42
+ }
43
+
44
+ /** The fixtures the gate can run. @experimental */
45
+ export const ENCODER_GATE_FIXTURES = [
46
+ 'short',
47
+ 'acceptance',
48
+ 'silence',
49
+ 'saturated',
50
+ 'markers',
51
+ ] as const
52
+
53
+ /**
54
+ * Encode one fixture and describe the result completely.
55
+ *
56
+ * Never throws: a gate that is unavailable is an answer.
57
+ *
58
+ * @experimental
59
+ */
60
+ export function runEncoderGate(fixture: string, maxInputSize = 65536): EncoderGateResult {
61
+ try {
62
+ return JSON.parse(requireNative().encoderGateRun(fixture, maxInputSize)) as EncoderGateResult
63
+ } catch (error) {
64
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
65
+ }
66
+ }
@@ -0,0 +1,149 @@
1
+ import type { VideoExportDestination } from '@pictor-sdk/video-core'
2
+
3
+ /**
4
+ * Where the file goes, and what is refused before anything is encoded.
5
+ *
6
+ * ## Refusal, never fallback
7
+ *
8
+ * A destination that cannot be honoured fails the export naming the reason. The
9
+ * tempting alternative — write to the cache and return that instead — produces
10
+ * an export that reports success while the file is nowhere the caller asked for,
11
+ * and nothing downstream can tell that apart from having worked. A host that
12
+ * wants a temporary can ask for one.
13
+ *
14
+ * ## `file:` only, for now
15
+ *
16
+ * A `content:` uri is not a path. A document provider need not be a filesystem,
17
+ * so there is no directory to stage in and no rename to be atomic about;
18
+ * publishing would have to be a copy into an opaque stream, with no way to leave
19
+ * the previous contents intact if it failed halfway. That is a different design,
20
+ * not a missing branch, so it is refused by name.
21
+ *
22
+ * ## Paths do not appear in errors
23
+ *
24
+ * A message that quotes `/data/user/0/com.host.app/files/private/...` puts an
25
+ * app-private path into a log, a crash report and whatever the host displays.
26
+ * Every reason here names the file and nothing above it.
27
+ *
28
+ * @experimental
29
+ */
30
+
31
+ /** A destination this adapter can honour. @experimental */
32
+ export type ResolvedDestination =
33
+ | { kind: 'temporary' }
34
+ | { kind: 'file'; path: string; overwrite: boolean }
35
+
36
+ /** @experimental */
37
+ export interface DestinationDecision {
38
+ destination: ResolvedDestination | null
39
+ /** Empty when `destination` is set. Never contains a full path. */
40
+ reasons: string[]
41
+ }
42
+
43
+ /**
44
+ * The last segment, for a message that has to name something.
45
+ *
46
+ * Directories are the private part. A filename the caller chose is already
47
+ * theirs, and without it a refusal cannot say which destination it refused.
48
+ */
49
+ export function describeTarget(path: string): string {
50
+ const trimmed = path.replace(/\/+$/, '')
51
+ const name = trimmed.slice(trimmed.lastIndexOf('/') + 1)
52
+ return name.length > 0 ? name : '(unnamed)'
53
+ }
54
+
55
+ const KNOWN_SCHEMES: Record<string, string> = {
56
+ 'content:': 'a content uri is not a filesystem path, so it cannot be published atomically',
57
+ 'http:': 'a remote destination cannot be written to directly',
58
+ 'https:': 'a remote destination cannot be written to directly',
59
+ 'android.resource:': 'app resources are read-only',
60
+ 'asset:': 'bundled assets are read-only',
61
+ }
62
+
63
+ /** @experimental */
64
+ export function resolveDestination(destination?: VideoExportDestination): DestinationDecision {
65
+ if (destination === undefined) return { destination: { kind: 'temporary' }, reasons: [] }
66
+ if (destination.kind === 'temporary') return { destination: { kind: 'temporary' }, reasons: [] }
67
+ if (destination.kind !== 'file') {
68
+ return {
69
+ destination: null,
70
+ reasons: [`destination kind '${String((destination as { kind: string }).kind)}' is not supported`],
71
+ }
72
+ }
73
+
74
+ const uri = destination.uri
75
+ if (typeof uri !== 'string' || uri.trim().length === 0) {
76
+ return { destination: null, reasons: ['the file destination has no uri'] }
77
+ }
78
+
79
+ const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.exec(uri)?.[0]?.toLowerCase()
80
+ if (scheme !== undefined && scheme !== 'file:') {
81
+ const known = KNOWN_SCHEMES[scheme]
82
+ return {
83
+ destination: null,
84
+ reasons: [
85
+ known !== undefined
86
+ ? `destination scheme '${scheme}' is not supported: ${known}`
87
+ : `destination scheme '${scheme}' is not supported; only file: is`,
88
+ ],
89
+ }
90
+ }
91
+ if (scheme === undefined) {
92
+ // A bare path is ambiguous — relative to what? — and accepting it would
93
+ // silently resolve against a working directory nobody set.
94
+ return { destination: null, reasons: ['the file destination must be a file: uri'] }
95
+ }
96
+
97
+ let path: string
98
+ try {
99
+ // `file:///a/b` and `file:/a/b` both name an absolute path; `file://host/a`
100
+ // names another machine's, which is not something to guess at.
101
+ const withoutScheme = uri.slice('file:'.length)
102
+ const rest = withoutScheme.startsWith('//') ? withoutScheme.slice(2) : withoutScheme
103
+ if (withoutScheme.startsWith('//') && !rest.startsWith('/')) {
104
+ return {
105
+ destination: null,
106
+ reasons: ['the file destination names a remote host, which is not supported'],
107
+ }
108
+ }
109
+ path = decodeURIComponent(rest)
110
+ } catch {
111
+ return { destination: null, reasons: ['the file destination uri is malformed'] }
112
+ }
113
+
114
+ if (!path.startsWith('/')) {
115
+ return { destination: null, reasons: ['the file destination must be an absolute path'] }
116
+ }
117
+ if (path.endsWith('/')) {
118
+ return {
119
+ destination: null,
120
+ reasons: ['the file destination is a directory; it must name the file to write'],
121
+ }
122
+ }
123
+ if (path.includes('\0')) {
124
+ return { destination: null, reasons: ['the file destination uri is malformed'] }
125
+ }
126
+ /*
127
+ `..` is refused rather than normalised.
128
+
129
+ Resolving it would mean a destination that names one directory and writes to
130
+ another, and the staging file — which must live beside the target for the
131
+ publication to be a same-filesystem rename — would be placed by arithmetic
132
+ the caller did not do.
133
+ */
134
+ if (path.split('/').some((segment) => segment === '..')) {
135
+ return { destination: null, reasons: ['the file destination must not contain ".."'] }
136
+ }
137
+ const name = describeTarget(path)
138
+ if (name === '.' || name === '(unnamed)') {
139
+ return {
140
+ destination: null,
141
+ reasons: ['the file destination is a directory; it must name the file to write'],
142
+ }
143
+ }
144
+
145
+ return {
146
+ destination: { kind: 'file', path, overwrite: destination.overwrite === true },
147
+ reasons: [],
148
+ }
149
+ }