@octane-xplat/camera 0.13.0 → 0.14.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 (81) hide show
  1. package/README.md +63 -3
  2. package/dist/linux/CameraView.web.tsrx.js +337 -0
  3. package/dist/linux/host-movie.web.js +100 -0
  4. package/dist/linux/index.js +5 -0
  5. package/dist/linux/layout-child.web.js +29 -0
  6. package/dist/linux/movie-metadata.web.js +65 -0
  7. package/dist/linux/octane-client-build.json +8 -0
  8. package/dist/linux/session-backend.linux.js +479 -0
  9. package/dist/linux/session-core.js +368 -0
  10. package/dist/linux/session.js +29 -0
  11. package/dist/linux/synthetic-session.js +280 -0
  12. package/dist/linux/types.js +19 -0
  13. package/dist/native/CameraView.android.js +25 -1
  14. package/dist/native/CameraView.ios.js +24 -1
  15. package/dist/native/CameraView.windows.js +173 -0
  16. package/dist/native/camera-permission.windows.js +43 -0
  17. package/dist/native/camera-session.windows.js +78 -0
  18. package/dist/native/cx.windows.js +9 -0
  19. package/dist/native/index.js +4 -1
  20. package/dist/native/layout-child.windows.js +53 -0
  21. package/dist/native/octane-client-build.json +1 -1
  22. package/dist/native/session-backend.android.js +234 -0
  23. package/dist/native/session-backend.ios.js +682 -0
  24. package/dist/native/session-backend.js +55 -0
  25. package/dist/native/session-backend.windows.js +905 -0
  26. package/dist/native/session-core.js +368 -0
  27. package/dist/native/session.js +29 -0
  28. package/dist/native/synthetic-session.js +280 -0
  29. package/dist/native/types.js +19 -0
  30. package/dist/web/CameraView.web.tsrx.js +27 -7
  31. package/dist/web/host-movie.web.js +100 -0
  32. package/dist/web/index.js +4 -1
  33. package/dist/web/movie-metadata.web.js +65 -0
  34. package/dist/web/movie-storage.web.js +119 -0
  35. package/dist/web/octane-client-build.json +1 -1
  36. package/dist/web/session-backend.web.js +490 -0
  37. package/dist/web/session-core.js +368 -0
  38. package/dist/web/session.js +29 -0
  39. package/dist/web/synthetic-session.js +280 -0
  40. package/dist/web/types.js +19 -0
  41. package/package.json +22 -4
  42. package/platforms/android/AndroidManifest.xml +2 -0
  43. package/platforms/android/include.gradle +1 -0
  44. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
  45. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
  46. package/platforms/ios/Info.plist +2 -0
  47. package/platforms/macos/src/XplatCameraHost.swift +513 -0
  48. package/src/CameraView.android.tsrx +21 -1
  49. package/src/CameraView.ios.tsrx +28 -2
  50. package/src/CameraView.macos.tsrx +93 -0
  51. package/src/CameraView.web.tsrx +20 -2
  52. package/src/CameraView.windows.tsrx +124 -0
  53. package/src/camera-permission.windows.ts +76 -0
  54. package/src/camera-session.macos.ts +75 -0
  55. package/src/camera-session.windows.ts +123 -0
  56. package/src/cx.macos.ts +5 -0
  57. package/src/cx.windows.ts +6 -0
  58. package/src/host-movie.web.ts +180 -0
  59. package/src/index.ts +31 -0
  60. package/src/index.web.ts +31 -0
  61. package/src/layout-child.windows.ts +79 -0
  62. package/src/movie-metadata.web.ts +84 -0
  63. package/src/movie-storage.web.ts +164 -0
  64. package/src/props.ts +7 -1
  65. package/src/session-backend.android.ts +397 -0
  66. package/src/session-backend.ios.ts +1092 -0
  67. package/src/session-backend.linux.ts +869 -0
  68. package/src/session-backend.macos.ts +624 -0
  69. package/src/session-backend.ts +57 -0
  70. package/src/session-backend.web.ts +874 -0
  71. package/src/session-backend.windows.ts +1517 -0
  72. package/src/session-core.ts +659 -0
  73. package/src/session.ts +37 -0
  74. package/src/synthetic-session.ts +367 -0
  75. package/src/types.ts +329 -0
  76. package/types/index.d.ts +43 -0
  77. package/types/props.d.ts +7 -1
  78. package/types/session-core.d.ts +129 -0
  79. package/types/synthetic-session.d.ts +48 -0
  80. package/types/types.d.ts +263 -0
  81. package/src/CameraView.windows.ts +0 -5
@@ -0,0 +1,367 @@
1
+ import { SharedCameraSession } from './session-core'
2
+ import type {
3
+ BackendCaptureRequest,
4
+ BackendCaptureSink,
5
+ BackendObserver,
6
+ BackendSettle,
7
+ CameraSessionBackend,
8
+ } from './session-core'
9
+
10
+ import type {
11
+ CameraInterruptionReason,
12
+ CameraPermissionStatus,
13
+ CameraSession,
14
+ CameraSessionCapabilities,
15
+ CameraSessionConfig,
16
+ MovieOutput,
17
+ } from './types'
18
+
19
+ /** Development/testing seam — a deterministic camera source that drives
20
+ * the shared session state machine without hardware. NOT a production
21
+ * recording source: outcomes carry `synthetic: true` on their output so
22
+ * seam clips stay distinguishable from real capture.
23
+ *
24
+ * `script` is read live at each transition — mutate fields between
25
+ * operations to script permissions, delayed starts, interruptions,
26
+ * partial completions, and failures.
27
+ */
28
+ export interface SyntheticCameraScript {
29
+ permission: {
30
+ camera: CameraPermissionStatus
31
+ microphone: CameraPermissionStatus
32
+ }
33
+ /** Camera hardware availability observation. */
34
+ available: boolean
35
+ /** Delay before `started` reports; models slow native activation. */
36
+ startDelayMs: number
37
+ /** Finalized clip duration reported by the seam. */
38
+ clipDurationMs: number
39
+ /** Produce a usable clip on stop; false settles `noMedia`. */
40
+ produceMedia: boolean
41
+ /** When set, the attempt settles failed at this stage. */
42
+ failAt?: 'start' | 'capture' | 'finalize' | 'storage'
43
+ /** Emit an interruption after this many ms of recording; the take
44
+ * ends with the mapped end reason and a partial clip. */
45
+ interruption?: {
46
+ afterMs: number
47
+ reason: CameraInterruptionReason
48
+ endsAfterMs?: number
49
+ }
50
+ }
51
+
52
+ export interface SyntheticCameraStorage {
53
+ write(path: string, bytes: Uint8Array): void | Promise<void>
54
+ read(path: string): Uint8Array | undefined | Promise<Uint8Array | undefined>
55
+ delete?(path: string): void | Promise<void>
56
+ exists?(path: string): boolean | Promise<boolean>
57
+ }
58
+
59
+ /** In-memory storage for the seam — a `fileUrl`-shaped map so outcomes
60
+ * exercise the same reopen path without touching a real filesystem. */
61
+ export function createMemoryCameraStorage(): SyntheticCameraStorage & {
62
+ files: Map<string, Uint8Array>
63
+ } {
64
+ const files = new Map<string, Uint8Array>()
65
+ return {
66
+ files,
67
+ write: (path, bytes) => {
68
+ files.set(path, bytes)
69
+ },
70
+ read: (path) => files.get(path),
71
+ delete: (path) => {
72
+ files.delete(path)
73
+ },
74
+ exists: (path) => files.has(path),
75
+ }
76
+ }
77
+
78
+ const defaultScript = (): SyntheticCameraScript => ({
79
+ permission: { camera: 'granted', microphone: 'granted' },
80
+ available: true,
81
+ startDelayMs: 20,
82
+ clipDurationMs: 3000,
83
+ produceMedia: true,
84
+ })
85
+
86
+ const capabilities: CameraSessionCapabilities = {
87
+ supported: true,
88
+ available: true,
89
+ cameras: [
90
+ { id: 'synthetic-back', label: 'Synthetic back camera', facing: 'back' },
91
+ { id: 'synthetic-front', label: 'Synthetic front camera', facing: 'front' },
92
+ ],
93
+ profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
94
+ audio: true,
95
+ orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
96
+ durationLimit: 'native',
97
+ elapsedTime: 'nativeMedia',
98
+ output: {
99
+ mimeType: 'video/quicktime',
100
+ container: 'mov',
101
+ storage: 'appPrivateFile',
102
+ destinationFileUrl: true,
103
+ },
104
+ }
105
+
106
+ /** Create a `CameraSession` backed by the deterministic synthetic source.
107
+ * Marked for development and testing — never present this as live
108
+ * capture to users. */
109
+ export function createSyntheticCameraSession(
110
+ config: CameraSessionConfig = {},
111
+ script: Partial<SyntheticCameraScript> = {},
112
+ storage: SyntheticCameraStorage = createMemoryCameraStorage(),
113
+ ): CameraSession {
114
+ const controls: SyntheticCameraScript = { ...defaultScript(), ...script }
115
+ return new SharedCameraSession(createSyntheticBackend(controls, storage), config)
116
+ }
117
+
118
+ function createSyntheticBackend(
119
+ script: SyntheticCameraScript,
120
+ storage: SyntheticCameraStorage,
121
+ ): CameraSessionBackend {
122
+ let observer: BackendObserver | undefined
123
+ let attached = false
124
+ let sink: BackendCaptureSink | undefined
125
+ let startedAt: number | undefined
126
+ let timers: ReturnType<typeof setTimeout>[] = []
127
+ let pendingRequest: BackendCaptureRequest | undefined
128
+
129
+ const later = (ms: number, fn: () => void) => {
130
+ timers.push(setTimeout(fn, ms))
131
+ }
132
+
133
+ const clearTimers = () => {
134
+ for (const timer of timers) {
135
+ clearTimeout(timer)
136
+ }
137
+
138
+ timers = []
139
+ }
140
+
141
+ const settleClip = async (request: BackendCaptureRequest, limitReached: boolean) => {
142
+ const path =
143
+ request.destinationPath ??
144
+ `/synthetic/clip-${Date.now()}-${Math.random().toString(36).slice(2)}.mov`
145
+
146
+ if (!script.produceMedia) {
147
+ sink?.settled({
148
+ kind: 'failed',
149
+ stage: 'capture',
150
+ issue: {
151
+ kind: 'noMedia',
152
+ operation: 'finalize',
153
+ message: 'Synthetic source produced no media',
154
+ },
155
+ })
156
+
157
+ return
158
+ }
159
+
160
+ if (script.failAt === 'finalize') {
161
+ sink?.settled({
162
+ kind: 'failed',
163
+ stage: 'finalization',
164
+ issue: {
165
+ kind: 'finalizationFailed',
166
+ operation: 'finalize',
167
+ message: 'Synthetic finalization failure',
168
+ },
169
+ })
170
+
171
+ return
172
+ }
173
+
174
+ if (script.failAt === 'storage') {
175
+ sink?.settled({
176
+ kind: 'failed',
177
+ stage: 'storage',
178
+ issue: {
179
+ kind: 'storageFailed',
180
+ operation: 'store',
181
+ message: 'Synthetic storage failure',
182
+ },
183
+ })
184
+
185
+ return
186
+ }
187
+
188
+ try {
189
+ // Not playable media — seam bytes stand in for a fixture. The
190
+ // output is marked `synthetic` so nothing mistakes it for real
191
+ // capture.
192
+ await storage.write(path, new Uint8Array([0x78, 0x70, 0x6c, 0x61, 0x74]))
193
+ } catch (cause) {
194
+ sink?.settled({
195
+ kind: 'failed',
196
+ stage: 'storage',
197
+ issue: {
198
+ kind: 'storageFailed',
199
+ operation: 'store',
200
+ message: cause instanceof Error ? cause.message : String(cause),
201
+ cause,
202
+ },
203
+ })
204
+
205
+ return
206
+ }
207
+
208
+ const output: MovieOutput = {
209
+ kind: 'nativeFile',
210
+ resourceId: `synthetic:${path}`,
211
+ fileUrl: `file://${path}`,
212
+ synthetic: true,
213
+ }
214
+
215
+ const settle: BackendSettle = {
216
+ kind: 'clip',
217
+ output,
218
+ mimeType: 'video/quicktime',
219
+ durationMs: script.clipDurationMs,
220
+ width: 1280,
221
+ height: 720,
222
+ orientation: request.orientation ?? 'portrait',
223
+ hasAudio: request.audio,
224
+ limitReached,
225
+ }
226
+
227
+ sink?.settled(settle)
228
+ }
229
+
230
+ return {
231
+ platform: 'synthetic',
232
+ supported: true,
233
+ checkPermission: async (kind) => script.permission[kind],
234
+ requestPermission: async (kind) => script.permission[kind],
235
+ getCapabilities: async () => ({
236
+ ...capabilities,
237
+ available: script.available,
238
+ reason: script.available ? undefined : 'Synthetic camera is unavailable',
239
+ }),
240
+ previewReady: () => attached && script.available,
241
+ attachPreview: (_host, listener) => {
242
+ attached = true
243
+ queueMicrotask(() => {
244
+ listener?.onReady?.()
245
+ observer?.previewReady(true)
246
+ })
247
+
248
+ return () => {
249
+ attached = false
250
+ observer?.previewReady(false)
251
+ }
252
+ },
253
+ startCapture: (request, attemptSink) => {
254
+ sink = attemptSink
255
+ pendingRequest = request
256
+ if (!script.available) {
257
+ attemptSink.settled({
258
+ kind: 'failed',
259
+ stage: 'preparation',
260
+ issue: {
261
+ kind: 'unavailable',
262
+ operation: 'startRecording',
263
+ message: 'Synthetic camera is unavailable',
264
+ },
265
+ })
266
+
267
+ return
268
+ }
269
+
270
+ if (script.failAt === 'start') {
271
+ later(script.startDelayMs, () => {
272
+ attemptSink.settled({
273
+ kind: 'failed',
274
+ stage: 'preparation',
275
+ issue: {
276
+ kind: 'captureFailed',
277
+ operation: 'startRecording',
278
+ message: 'Synthetic start failure',
279
+ },
280
+ })
281
+ })
282
+
283
+ return
284
+ }
285
+
286
+ later(script.startDelayMs, () => {
287
+ startedAt = Date.now()
288
+ attemptSink.started({
289
+ cameraId: 'synthetic-back',
290
+ facing: 'back',
291
+ audio: request.audio,
292
+ width: 1280,
293
+ height: 720,
294
+ frameRate: 30,
295
+ orientation: request.orientation ?? 'portrait',
296
+ mimeType: 'video/quicktime',
297
+ container: 'mov',
298
+ })
299
+
300
+ if (script.interruption) {
301
+ later(script.interruption.afterMs, () => {
302
+ observer?.interruption('began', script.interruption?.reason)
303
+ if (script.interruption?.endsAfterMs !== undefined) {
304
+ later(script.interruption.endsAfterMs, () => {
305
+ observer?.interruption('ended', script.interruption?.reason)
306
+ })
307
+ }
308
+ })
309
+ }
310
+
311
+ if (request.maximumDurationMs !== undefined) {
312
+ later(request.maximumDurationMs, () => {
313
+ void settleClip(request, true)
314
+ })
315
+ }
316
+
317
+ if (script.failAt === 'capture') {
318
+ later(50, () => {
319
+ attemptSink.settled({
320
+ kind: 'failed',
321
+ stage: 'capture',
322
+ issue: {
323
+ kind: 'captureFailed',
324
+ operation: 'record',
325
+ message: 'Synthetic capture failure',
326
+ },
327
+ })
328
+ })
329
+ }
330
+ })
331
+ },
332
+ stopCapture: () => {
333
+ // Settle asynchronously — native stop is async too.
334
+ const request = pendingRequest
335
+ later(5, () => {
336
+ if (request) {
337
+ void settleClip(request, false)
338
+ }
339
+ })
340
+ },
341
+ elapsedMs: () => (startedAt === undefined ? 0 : Date.now() - startedAt),
342
+ openOutput: async (output) => {
343
+ const path = output.kind === 'nativeFile' ? output.fileUrl.slice('file://'.length) : ''
344
+ const exists = storage.exists ? await storage.exists(path) : !!(await storage.read(path))
345
+ if (!exists) {
346
+ throw new Error(`Synthetic clip is missing at ${output.resourceId}`)
347
+ }
348
+
349
+ return {
350
+ url: output.kind === 'nativeFile' ? output.fileUrl : output.resourceId,
351
+ fileUrl: output.kind === 'nativeFile' ? output.fileUrl : undefined,
352
+ release: () => {},
353
+ }
354
+ },
355
+ release: async () => {
356
+ attached = false
357
+ clearTimers()
358
+ sink = undefined
359
+ startedAt = undefined
360
+ pendingRequest = undefined
361
+ },
362
+ onPreviewDetached: () => {},
363
+ setObserver: (next) => {
364
+ observer = next
365
+ },
366
+ }
367
+ }
package/src/types.ts ADDED
@@ -0,0 +1,329 @@
1
+ /** Shared camera-session and movie-recording contract.
2
+ *
3
+ * A `CameraSession` owns one camera acquisition shared between a
4
+ * `CameraView` preview and at most one movie-recording attempt. Recording
5
+ * settles with an immutable `MovieOutcome` only after the local movie is
6
+ * finalized and its metadata is known — a returned clip is always
7
+ * reopenable through `openOutput`.
8
+ *
9
+ * This module is platform-pure: adapters implement
10
+ * `CameraSessionBackend` per host. Adapters that do not support capture
11
+ * yet report `supported: false` and reject `startRecording` with
12
+ * `unsupportedPlatform` instead of fabricating behavior.
13
+ */
14
+
15
+ /** Permission resource a `CameraSession` can inspect or request. */
16
+ export type CameraPermissionKind = 'camera' | 'microphone'
17
+
18
+ /** Portable authorization state. Checking never prompts. `blocked` and
19
+ * `unknown` exist so hosts that cannot distinguish user denial from
20
+ * policy refusal do not fabricate `restricted`. */
21
+ export type CameraPermissionStatus =
22
+ | 'notDetermined'
23
+ | 'granted'
24
+ | 'denied'
25
+ | 'restricted'
26
+ | 'blocked'
27
+ | 'unknown'
28
+ | 'unavailable'
29
+
30
+ /** Portable error categories. `unsupported*` covers missing host features,
31
+ * devices, or capture configurations — including adapters that are not
32
+ * implemented yet during staged delivery. */
33
+ export type CameraCaptureErrorKind =
34
+ | 'permissionDenied'
35
+ | 'permissionRestricted'
36
+ | 'permissionBlocked'
37
+ | 'unsupportedPlatform'
38
+ | 'unsupportedConfiguration'
39
+ | 'unavailable'
40
+ | 'busy'
41
+ | 'invalidState'
42
+ | 'invalidArgument'
43
+ | 'destinationUnavailable'
44
+ | 'insufficientStorage'
45
+ | 'noMedia'
46
+ | 'captureFailed'
47
+ | 'finalizationFailed'
48
+ | 'storageFailed'
49
+ | 'configurationMissing'
50
+
51
+ /** Structured diagnostic carried by failures and non-fatal warnings. */
52
+ export interface CaptureIssue {
53
+ kind: CameraCaptureErrorKind
54
+ /** Operation being attempted, e.g. 'startRecording', 'finalize'. */
55
+ operation: string
56
+ message: string
57
+ permission?: CameraPermissionKind
58
+ /** Host-specific detail for logging; never required for control flow. */
59
+ cause?: unknown
60
+ }
61
+
62
+ /** Error thrown or settled by the session API. Satisfies `CaptureIssue`
63
+ * so `error.kind` drives control flow without unwrapping. */
64
+ export class CameraCaptureError extends Error implements CaptureIssue {
65
+ kind: CameraCaptureErrorKind
66
+ operation: string
67
+ permission?: CameraPermissionKind
68
+ cause?: unknown
69
+
70
+ constructor(issue: Omit<CaptureIssue, 'cause'> & { cause?: unknown }) {
71
+ super(issue.message)
72
+ this.name = 'CameraCaptureError'
73
+ this.kind = issue.kind
74
+ this.operation = issue.operation
75
+ this.permission = issue.permission
76
+ this.cause = issue.cause
77
+ }
78
+ }
79
+
80
+ export type CameraFacing = 'front' | 'back'
81
+
82
+ export type CameraOrientation =
83
+ | 'portrait'
84
+ | 'portraitUpsideDown'
85
+ | 'landscapeLeft'
86
+ | 'landscapeRight'
87
+ | 'unspecified'
88
+
89
+ /** Camera selection for `CameraSessionConfig.camera`. Desktop and browser
90
+ * hosts expose device identities instead of front/back semantics. */
91
+ export type CameraSelection = 'default' | 'front' | 'back' | { deviceId: string }
92
+
93
+ export interface CameraSessionConfig {
94
+ /** Lens or device identity. Default `'default'`: rear when available. */
95
+ camera?: CameraSelection
96
+ /** Include an audio track; requires microphone permission. Default
97
+ * false — silent capture is a deliberate app choice, never a
98
+ * fallback from denied microphone access. */
99
+ audio?: boolean
100
+ /** Capture profile. `'standard'` targets 720p at 30 fps and may select
101
+ * a device-supported alternative; the started attempt discloses the
102
+ * actual configuration. */
103
+ profile?: 'standard'
104
+ }
105
+
106
+ /** Resolution/frame-rate combination advertised by capability discovery.
107
+ * Entries are supported combinations, not independent lists. */
108
+ export interface CameraProfileCapability {
109
+ profile: 'standard'
110
+ width: number
111
+ height: number
112
+ frameRate: number
113
+ }
114
+
115
+ export interface CameraDeviceInfo {
116
+ id: string
117
+ label: string
118
+ facing?: CameraFacing
119
+ }
120
+
121
+ /** What the session's host can do right now. `supported: false` means the
122
+ * adapter or host cannot record (reason explains); `available` reflects
123
+ * current hardware/permission state and changes over time. */
124
+ export interface CameraSessionCapabilities {
125
+ supported: boolean
126
+ /** Why capture is unsupported or currently unavailable. */
127
+ reason?: string
128
+ available: boolean
129
+ cameras: CameraDeviceInfo[]
130
+ profiles: CameraProfileCapability[]
131
+ /** The session can add an audio track when permission allows. */
132
+ audio: boolean
133
+ /** Fixed orientations the source can lock for a take. */
134
+ orientations: CameraOrientation[]
135
+ /** `native` enforcement can still overshoot at media boundaries;
136
+ * `bestEffort` limits are scheduled and may overshoot more. */
137
+ durationLimit: 'native' | 'bestEffort'
138
+ /** Where `elapsedMs` comes from while recording. */
139
+ elapsedTime: 'nativeMedia' | 'estimated'
140
+ output: {
141
+ mimeType: string
142
+ container: string
143
+ /** `appPrivateFile` output survives dispose and app restart;
144
+ * `originLocal` is browser storage subject to site-data clearing. */
145
+ storage: 'appPrivateFile' | 'originLocal'
146
+ /** Accepts an app-supplied `destinationFileUrl` on start. */
147
+ destinationFileUrl: boolean
148
+ }
149
+ }
150
+
151
+ export type CameraRecorderState = 'idle' | 'starting' | 'recording' | 'finalizing' | 'disposed'
152
+
153
+ /** Frozen capture configuration reported once capture actually begins. */
154
+ export interface EffectiveCaptureConfig {
155
+ cameraId: string
156
+ facing?: CameraFacing
157
+ audio: boolean
158
+ width: number
159
+ height: number
160
+ frameRate: number
161
+ orientation: CameraOrientation
162
+ mimeType: string
163
+ container: string
164
+ }
165
+
166
+ export interface CameraSessionSnapshot {
167
+ state: CameraRecorderState
168
+ /** Non-prompting last-known authorization, refreshed by `permissions()`
169
+ * and `requestPermission()`. */
170
+ permission: Record<CameraPermissionKind, CameraPermissionStatus>
171
+ /** Camera hardware/driver availability, separate from recorder state. */
172
+ cameraAvailable: boolean
173
+ /** An active preview view is attached to this session. */
174
+ previewAttached: boolean
175
+ interrupted: boolean
176
+ attempt?: {
177
+ id: string
178
+ state: 'starting' | 'recording' | 'finalizing'
179
+ elapsedMs: number
180
+ configuration?: EffectiveCaptureConfig
181
+ }
182
+ }
183
+
184
+ /** What ended a capture attempt. `interrupted` covers system suspension
185
+ * and preview teardown; `previewReleased` distinguishes an app-driven
186
+ * deactivation from an OS interruption. */
187
+ export type MovieEndReason =
188
+ | 'stopped'
189
+ | 'maximumDuration'
190
+ | 'interrupted'
191
+ | 'backgrounded'
192
+ | 'cameraUnavailable'
193
+ | 'previewReleased'
194
+ | 'disposed'
195
+ | 'captureError'
196
+
197
+ /** Portable completed-output reference. `resourceId` reopens the clip
198
+ * through `openOutput`; `fileUrl` additionally guarantees file-backed
199
+ * native output. `synthetic` marks test-seam clips. */
200
+ export type MovieOutput =
201
+ | {
202
+ kind: 'nativeFile'
203
+ resourceId: string
204
+ fileUrl: string
205
+ synthetic?: boolean
206
+ }
207
+ | {
208
+ kind: 'browserStorage'
209
+ resourceId: string
210
+ retention: 'persistent' | 'bestEffort'
211
+ synthetic?: boolean
212
+ }
213
+
214
+ export interface MovieClip {
215
+ output: MovieOutput
216
+ mimeType: string
217
+ /** Authoritative duration read from the finalized movie. */
218
+ durationMs: number
219
+ /** Presentation dimensions after the orientation transform. */
220
+ width: number
221
+ height: number
222
+ orientation: CameraOrientation
223
+ /** Whether the finalized movie actually contains an audio track.
224
+ * Missing requested audio is a failure, not silent capture. */
225
+ hasAudio: boolean
226
+ }
227
+
228
+ /** One immutable terminal result per admitted attempt. `partial` marks a
229
+ * usable clip ended early by an external condition — still a success;
230
+ * app review decides whether to keep it. */
231
+ export type MovieOutcome =
232
+ | {
233
+ kind: 'clip'
234
+ clip: MovieClip
235
+ endReason: MovieEndReason
236
+ partial: boolean
237
+ warning?: CaptureIssue
238
+ }
239
+ | {
240
+ kind: 'failed'
241
+ endReason: MovieEndReason
242
+ stage: 'preparation' | 'capture' | 'finalization' | 'storage'
243
+ error: CaptureIssue
244
+ }
245
+
246
+ /** Ordered lifecycle observation, correlated to an attempt when relevant. */
247
+ export type CameraSessionEvent =
248
+ | { type: 'stateChanged'; snapshot: CameraSessionSnapshot }
249
+ | { type: 'started'; attemptId: string; configuration: EffectiveCaptureConfig }
250
+ | { type: 'finished'; attemptId: string; outcome: MovieOutcome }
251
+ | {
252
+ type: 'interruption'
253
+ phase: 'began' | 'ended'
254
+ reason?: CameraInterruptionReason
255
+ }
256
+ | { type: 'availability'; available: boolean }
257
+
258
+ export type CameraInterruptionReason =
259
+ | 'background'
260
+ | 'audioInUse'
261
+ | 'cameraInUse'
262
+ | 'multitasking'
263
+ | 'systemPressure'
264
+ | 'unknown'
265
+
266
+ export interface StartRecordingOptions {
267
+ /** Positive finite milliseconds. Enforcement precision comes from
268
+ * `capabilities().durationLimit`; the final duration is read from
269
+ * the movie and may overshoot. */
270
+ maximumDurationMs?: number
271
+ /** Fixed orientation for the take. Must appear in
272
+ * `capabilities().orientations`; omit for the source default. */
273
+ orientation?: CameraOrientation
274
+ /** App-supplied writable local `file://` URL (native hosts only,
275
+ * where `capabilities().output.destinationFileUrl` is true).
276
+ * Existing files are never overwritten. */
277
+ destinationFileUrl?: string
278
+ }
279
+
280
+ /** One admitted capture attempt. `completion` is available immediately and
281
+ * settles exactly once with the terminal outcome; repeated `stop()`
282
+ * joins the same finalization. */
283
+ export interface MovieTake {
284
+ readonly id: string
285
+ readonly state: 'starting' | 'recording' | 'finalizing' | 'settled'
286
+ /** Elapsed capture time in milliseconds; excludes preparation and
287
+ * finalization. Source identified by `capabilities().elapsedTime`. */
288
+ readonly elapsedMs: number
289
+ readonly completion: Promise<MovieOutcome>
290
+ /** Requests stop and joins finalization. Returns `completion`. */
291
+ stop(): Promise<MovieOutcome>
292
+ }
293
+
294
+ /** Reopened access to a stored clip. `release()` frees temporary access;
295
+ * it never deletes the clip. */
296
+ export interface MovieOutputHandle {
297
+ /** Playable/readable location for the clip. */
298
+ readonly url: string
299
+ readonly fileUrl?: string
300
+ release(): void
301
+ }
302
+
303
+ export interface CameraSession {
304
+ /** Non-prompting permission inspection; may return `unknown` when the
305
+ * host cannot observe authorization. */
306
+ permissions(): Promise<Record<CameraPermissionKind, CameraPermissionStatus>>
307
+ /** Runs the system authorization flow for one kind. Calls are
308
+ * serialized; a status other than `notDetermined` returns without
309
+ * prompting. */
310
+ requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>
311
+ capabilities(): Promise<CameraSessionCapabilities>
312
+ snapshot(): CameraSessionSnapshot
313
+ /** Ordered lifecycle events. A caller never loses a result by
314
+ * subscribing late — `completion` is authoritative. Listener
315
+ * exceptions do not change capture. */
316
+ subscribe(listener: (event: CameraSessionEvent) => void): () => void
317
+ /** Replaces configuration while idle. Validates against current
318
+ * capabilities and rejects with `invalidState` during an attempt. */
319
+ configure(config: Partial<CameraSessionConfig>): Promise<void>
320
+ /** Admits one capture attempt. Throws `CameraCaptureError` before
321
+ * admission for invalid arguments, busy, invalid state, or
322
+ * unavailable camera — no attempt is created. */
323
+ startRecording(options?: StartRecordingOptions): MovieTake
324
+ /** Reopens a stored output for playback or reading. */
325
+ openOutput(output: MovieOutput): Promise<MovieOutputHandle>
326
+ /** Requests stop on any active attempt, awaits finalization, then
327
+ * releases camera ownership. */
328
+ dispose(): Promise<void>
329
+ }