@octane-xplat/camera 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +89 -3
  2. package/package.json +35 -11
  3. package/platforms/android/AndroidManifest.xml +2 -0
  4. package/platforms/android/include.gradle +1 -0
  5. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  7. package/platforms/ios/Info.plist +2 -0
  8. package/platforms/macos/src/CameraOwnership.swift +22 -0
  9. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  10. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  11. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  12. package/src/CameraView.android.tsrx +27 -3
  13. package/src/CameraView.ios.tsrx +32 -4
  14. package/src/CameraView.macos.tsrx +95 -0
  15. package/src/CameraView.web.tsrx +31 -2
  16. package/src/CameraView.windows.tsrx +126 -0
  17. package/src/camera-permission.mobile.ts +18 -4
  18. package/src/camera-permission.windows.ts +76 -0
  19. package/src/camera-session.macos.ts +75 -0
  20. package/src/camera-session.windows.ts +126 -0
  21. package/src/host-movie.web.ts +295 -0
  22. package/src/index.ts +32 -0
  23. package/src/index.web.ts +32 -0
  24. package/src/layout-child.windows.ts +79 -0
  25. package/src/movie-metadata.web.ts +84 -0
  26. package/src/movie-storage.web.ts +164 -0
  27. package/src/preview-tap.ts +34 -0
  28. package/src/props.ts +14 -1
  29. package/src/session-backend.android.ts +439 -0
  30. package/src/session-backend.ios.ts +1258 -0
  31. package/src/session-backend.linux.ts +887 -0
  32. package/src/session-backend.macos.ts +707 -0
  33. package/src/session-backend.ts +59 -0
  34. package/src/session-backend.web.ts +891 -0
  35. package/src/session-backend.windows.ts +1599 -0
  36. package/src/session-core.ts +723 -0
  37. package/src/session.ts +37 -0
  38. package/src/synthetic-session.ts +377 -0
  39. package/src/types.ts +357 -0
  40. package/types/index.d.ts +43 -0
  41. package/types/props.d.ts +14 -1
  42. package/types/session-core.d.ts +139 -0
  43. package/types/synthetic-session.d.ts +51 -0
  44. package/types/types.d.ts +290 -0
  45. package/dist/native/CameraView.android.js +0 -166
  46. package/dist/native/CameraView.ios.js +0 -131
  47. package/dist/native/camera-permission.mobile.js +0 -45
  48. package/dist/native/camera-session.ios.js +0 -45
  49. package/dist/native/cx.mobile.js +0 -9
  50. package/dist/native/index.js +0 -2
  51. package/dist/native/layout-child.js +0 -53
  52. package/dist/native/octane-client-build.json +0 -8
  53. package/dist/web/CameraView.web.tsrx.js +0 -317
  54. package/dist/web/index.js +0 -2
  55. package/dist/web/layout-child.web.js +0 -29
  56. package/dist/web/octane-client-build.json +0 -8
  57. package/src/CameraView.windows.ts +0 -5
  58. package/src/cx.mobile.ts +0 -6
package/src/session.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { SharedCameraSession } from './session-core'
2
+ import type { BackendPreviewListener } from './session-core'
3
+ import { createSessionBackend } from './session-backend'
4
+ import { CameraCaptureError } from './types'
5
+ import type { CameraSession, CameraSessionConfig } from './types'
6
+
7
+ /** Create a shared camera owner: one acquisition shared between a
8
+ * `CameraView` preview (`session` prop) and at most one movie-recording
9
+ * attempt. Creating a session never prompts for permission or acquires
10
+ * hardware — both happen when a session-backed preview activates or a
11
+ * permission request runs.
12
+ *
13
+ * Recording support is delivered per platform; `capabilities()` reports
14
+ * honest `supported`/`available` state on every host. */
15
+ export function createCameraSession(config: CameraSessionConfig = {}): CameraSession {
16
+ return new SharedCameraSession(createSessionBackend(config), config)
17
+ }
18
+
19
+ /** Package-internal: attach `host` as the session's preview surface.
20
+ * Used by `CameraView` when its `session` prop is set; detaching the
21
+ * returned function requests stop on an in-flight attempt and releases
22
+ * camera ownership once finalization settles. */
23
+ export function attachSessionPreview(
24
+ session: CameraSession,
25
+ host: unknown,
26
+ listener?: BackendPreviewListener,
27
+ ): () => void {
28
+ if (!(session instanceof SharedCameraSession)) {
29
+ throw new CameraCaptureError({
30
+ kind: 'invalidArgument',
31
+ operation: 'attachPreview',
32
+ message: 'session was not created by createCameraSession',
33
+ })
34
+ }
35
+
36
+ return session.attach(host, listener)
37
+ }
@@ -0,0 +1,377 @@
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
+ CameraPreviewPoint,
14
+ CameraSession,
15
+ CameraSessionCapabilities,
16
+ CameraSessionConfig,
17
+ MovieOutput,
18
+ } from './types'
19
+
20
+ /** Development/testing seam — a deterministic camera source that drives
21
+ * the shared session state machine without hardware. NOT a production
22
+ * recording source: outcomes carry `synthetic: true` on their output so
23
+ * seam clips stay distinguishable from real capture.
24
+ *
25
+ * `script` is read live at each transition — mutate fields between
26
+ * operations to script permissions, delayed starts, interruptions,
27
+ * partial completions, and failures.
28
+ */
29
+ export interface SyntheticCameraScript {
30
+ permission: {
31
+ camera: CameraPermissionStatus
32
+ microphone: CameraPermissionStatus
33
+ }
34
+ /** Camera hardware availability observation. */
35
+ available: boolean
36
+ /** Delay before `started` reports; models slow native activation. */
37
+ startDelayMs: number
38
+ /** Finalized clip duration reported by the seam. */
39
+ clipDurationMs: number
40
+ /** Produce a usable clip on stop; false settles `noMedia`. */
41
+ produceMedia: boolean
42
+ /** When set, the attempt settles failed at this stage. */
43
+ failAt?: 'start' | 'capture' | 'finalize' | 'storage'
44
+ /** Emit an interruption after this many ms of recording; the take
45
+ * ends with the mapped end reason and a partial clip. */
46
+ interruption?: {
47
+ afterMs: number
48
+ reason: CameraInterruptionReason
49
+ endsAfterMs?: number
50
+ }
51
+ /** When set, each `focusAtPoint` call pushes its normalized point
52
+ * here so a spec can observe metering requests. */
53
+ focusRequests?: CameraPreviewPoint[]
54
+ }
55
+
56
+ export interface SyntheticCameraStorage {
57
+ write(path: string, bytes: Uint8Array): void | Promise<void>
58
+ read(path: string): Uint8Array | undefined | Promise<Uint8Array | undefined>
59
+ delete?(path: string): void | Promise<void>
60
+ exists?(path: string): boolean | Promise<boolean>
61
+ }
62
+
63
+ /** In-memory storage for the seam — a `fileUrl`-shaped map so outcomes
64
+ * exercise the same reopen path without touching a real filesystem. */
65
+ export function createMemoryCameraStorage(): SyntheticCameraStorage & {
66
+ files: Map<string, Uint8Array>
67
+ } {
68
+ const files = new Map<string, Uint8Array>()
69
+ return {
70
+ files,
71
+ write: (path, bytes) => {
72
+ files.set(path, bytes)
73
+ },
74
+ read: (path) => files.get(path),
75
+ delete: (path) => {
76
+ files.delete(path)
77
+ },
78
+ exists: (path) => files.has(path),
79
+ }
80
+ }
81
+
82
+ const defaultScript = (): SyntheticCameraScript => ({
83
+ permission: { camera: 'granted', microphone: 'granted' },
84
+ available: true,
85
+ startDelayMs: 20,
86
+ clipDurationMs: 3000,
87
+ produceMedia: true,
88
+ })
89
+
90
+ const capabilities: CameraSessionCapabilities = {
91
+ supported: true,
92
+ available: true,
93
+ cameras: [
94
+ { id: 'synthetic-back', label: 'Synthetic back camera', facing: 'back' },
95
+ { id: 'synthetic-front', label: 'Synthetic front camera', facing: 'front' },
96
+ ],
97
+ profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
98
+ audio: true,
99
+ // The seam accepts metering points so tests exercise the supported path.
100
+ focusPointOfInterest: true,
101
+ exposurePointOfInterest: true,
102
+ orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
103
+ durationLimit: 'native',
104
+ elapsedTime: 'nativeMedia',
105
+ output: {
106
+ mimeType: 'video/quicktime',
107
+ container: 'mov',
108
+ storage: 'appPrivateFile',
109
+ destinationFileUrl: true,
110
+ },
111
+ }
112
+
113
+ /** Create a `CameraSession` backed by the deterministic synthetic source.
114
+ * Marked for development and testing — never present this as live
115
+ * capture to users. */
116
+ export function createSyntheticCameraSession(
117
+ config: CameraSessionConfig = {},
118
+ script: Partial<SyntheticCameraScript> = {},
119
+ storage: SyntheticCameraStorage = createMemoryCameraStorage(),
120
+ ): CameraSession {
121
+ const controls: SyntheticCameraScript = { ...defaultScript(), ...script }
122
+ return new SharedCameraSession(createSyntheticBackend(controls, storage), config)
123
+ }
124
+
125
+ function createSyntheticBackend(
126
+ script: SyntheticCameraScript,
127
+ storage: SyntheticCameraStorage,
128
+ ): CameraSessionBackend {
129
+ let observer: BackendObserver | undefined
130
+ let attached = false
131
+ let sink: BackendCaptureSink | undefined
132
+ let startedAt: number | undefined
133
+ let timers: ReturnType<typeof setTimeout>[] = []
134
+ let pendingRequest: BackendCaptureRequest | undefined
135
+
136
+ const later = (ms: number, fn: () => void) => {
137
+ timers.push(setTimeout(fn, ms))
138
+ }
139
+
140
+ const clearTimers = () => {
141
+ for (const timer of timers) {
142
+ clearTimeout(timer)
143
+ }
144
+
145
+ timers = []
146
+ }
147
+
148
+ const settleClip = async (request: BackendCaptureRequest, limitReached: boolean) => {
149
+ const path =
150
+ request.destinationPath ??
151
+ `/synthetic/clip-${Date.now()}-${Math.random().toString(36).slice(2)}.mov`
152
+
153
+ if (!script.produceMedia) {
154
+ sink?.settled({
155
+ kind: 'failed',
156
+ stage: 'capture',
157
+ issue: {
158
+ kind: 'noMedia',
159
+ operation: 'finalize',
160
+ message: 'Synthetic source produced no media',
161
+ },
162
+ })
163
+
164
+ return
165
+ }
166
+
167
+ if (script.failAt === 'finalize') {
168
+ sink?.settled({
169
+ kind: 'failed',
170
+ stage: 'finalization',
171
+ issue: {
172
+ kind: 'finalizationFailed',
173
+ operation: 'finalize',
174
+ message: 'Synthetic finalization failure',
175
+ },
176
+ })
177
+
178
+ return
179
+ }
180
+
181
+ if (script.failAt === 'storage') {
182
+ sink?.settled({
183
+ kind: 'failed',
184
+ stage: 'storage',
185
+ issue: {
186
+ kind: 'storageFailed',
187
+ operation: 'store',
188
+ message: 'Synthetic storage failure',
189
+ },
190
+ })
191
+
192
+ return
193
+ }
194
+
195
+ try {
196
+ // Not playable media — seam bytes stand in for a fixture. The
197
+ // output is marked `synthetic` so nothing mistakes it for real
198
+ // capture.
199
+ await storage.write(path, new Uint8Array([0x78, 0x70, 0x6c, 0x61, 0x74]))
200
+ } catch (cause) {
201
+ sink?.settled({
202
+ kind: 'failed',
203
+ stage: 'storage',
204
+ issue: {
205
+ kind: 'storageFailed',
206
+ operation: 'store',
207
+ message: cause instanceof Error ? cause.message : String(cause),
208
+ cause,
209
+ },
210
+ })
211
+
212
+ return
213
+ }
214
+
215
+ const output: MovieOutput = {
216
+ kind: 'nativeFile',
217
+ resourceId: `synthetic:${path}`,
218
+ fileUrl: `file://${path}`,
219
+ synthetic: true,
220
+ }
221
+
222
+ const settle: BackendSettle = {
223
+ kind: 'clip',
224
+ output,
225
+ mimeType: 'video/quicktime',
226
+ durationMs: script.clipDurationMs,
227
+ width: 1280,
228
+ height: 720,
229
+ orientation: request.orientation ?? 'portrait',
230
+ hasAudio: request.audio,
231
+ limitReached,
232
+ }
233
+
234
+ sink?.settled(settle)
235
+ }
236
+
237
+ return {
238
+ platform: 'synthetic',
239
+ supported: true,
240
+ checkPermission: async (kind) => script.permission[kind],
241
+ requestPermission: async (kind) => script.permission[kind],
242
+ getCapabilities: async () => ({
243
+ ...capabilities,
244
+ available: script.available,
245
+ reason: script.available ? undefined : 'Synthetic camera is unavailable',
246
+ }),
247
+ previewReady: () => attached && script.available,
248
+ focusAtPoint: (point) => {
249
+ script.focusRequests?.push(point)
250
+ },
251
+ attachPreview: (_host, listener) => {
252
+ attached = true
253
+ queueMicrotask(() => {
254
+ listener?.onReady?.()
255
+ observer?.previewReady(true)
256
+ })
257
+
258
+ return () => {
259
+ attached = false
260
+ observer?.previewReady(false)
261
+ }
262
+ },
263
+ startCapture: (request, attemptSink) => {
264
+ sink = attemptSink
265
+ pendingRequest = request
266
+ if (!script.available) {
267
+ attemptSink.settled({
268
+ kind: 'failed',
269
+ stage: 'preparation',
270
+ issue: {
271
+ kind: 'unavailable',
272
+ operation: 'startRecording',
273
+ message: 'Synthetic camera is unavailable',
274
+ },
275
+ })
276
+
277
+ return
278
+ }
279
+
280
+ if (script.failAt === 'start') {
281
+ later(script.startDelayMs, () => {
282
+ attemptSink.settled({
283
+ kind: 'failed',
284
+ stage: 'preparation',
285
+ issue: {
286
+ kind: 'captureFailed',
287
+ operation: 'startRecording',
288
+ message: 'Synthetic start failure',
289
+ },
290
+ })
291
+ })
292
+
293
+ return
294
+ }
295
+
296
+ later(script.startDelayMs, () => {
297
+ startedAt = Date.now()
298
+ attemptSink.started({
299
+ cameraId: 'synthetic-back',
300
+ facing: 'back',
301
+ audio: request.audio,
302
+ width: 1280,
303
+ height: 720,
304
+ frameRate: 30,
305
+ orientation: request.orientation ?? 'portrait',
306
+ mimeType: 'video/quicktime',
307
+ container: 'mov',
308
+ })
309
+
310
+ if (script.interruption) {
311
+ later(script.interruption.afterMs, () => {
312
+ observer?.interruption('began', script.interruption?.reason)
313
+ if (script.interruption?.endsAfterMs !== undefined) {
314
+ later(script.interruption.endsAfterMs, () => {
315
+ observer?.interruption('ended', script.interruption?.reason)
316
+ })
317
+ }
318
+ })
319
+ }
320
+
321
+ if (request.maximumDurationMs !== undefined) {
322
+ later(request.maximumDurationMs, () => {
323
+ void settleClip(request, true)
324
+ })
325
+ }
326
+
327
+ if (script.failAt === 'capture') {
328
+ later(50, () => {
329
+ attemptSink.settled({
330
+ kind: 'failed',
331
+ stage: 'capture',
332
+ issue: {
333
+ kind: 'captureFailed',
334
+ operation: 'record',
335
+ message: 'Synthetic capture failure',
336
+ },
337
+ })
338
+ })
339
+ }
340
+ })
341
+ },
342
+ stopCapture: () => {
343
+ // Settle asynchronously — native stop is async too.
344
+ const request = pendingRequest
345
+ later(5, () => {
346
+ if (request) {
347
+ void settleClip(request, false)
348
+ }
349
+ })
350
+ },
351
+ elapsedMs: () => (startedAt === undefined ? 0 : Date.now() - startedAt),
352
+ openOutput: async (output) => {
353
+ const path = output.kind === 'nativeFile' ? output.fileUrl.slice('file://'.length) : ''
354
+ const exists = storage.exists ? await storage.exists(path) : !!(await storage.read(path))
355
+ if (!exists) {
356
+ throw new Error(`Synthetic clip is missing at ${output.resourceId}`)
357
+ }
358
+
359
+ return {
360
+ url: output.kind === 'nativeFile' ? output.fileUrl : output.resourceId,
361
+ fileUrl: output.kind === 'nativeFile' ? output.fileUrl : undefined,
362
+ release: () => {},
363
+ }
364
+ },
365
+ release: async () => {
366
+ attached = false
367
+ clearTimers()
368
+ sink = undefined
369
+ startedAt = undefined
370
+ pendingRequest = undefined
371
+ },
372
+ onPreviewDetached: () => {},
373
+ setObserver: (next) => {
374
+ observer = next
375
+ },
376
+ }
377
+ }