@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
@@ -0,0 +1,439 @@
1
+ import { Application } from '@nativescript/core'
2
+ import { CameraCaptureError } from './types'
3
+ import { canonicalizePath } from './session-core'
4
+ import type {
5
+ BackendCaptureSink,
6
+ BackendObserver,
7
+ BackendPreviewListener,
8
+ CameraSessionBackend,
9
+ } from './session-core'
10
+
11
+ import type {
12
+ CameraPermissionKind,
13
+ CameraPermissionStatus,
14
+ CameraSessionConfig,
15
+ CaptureIssue,
16
+ } from './types'
17
+
18
+ let requestCode = 7820
19
+ let permissionChain: Promise<unknown> = Promise.resolve()
20
+ let owner: object | undefined
21
+
22
+ const nativeBridge = () => (globalThis as any).com?.octanexplat?.camera?.CameraSessionBridge
23
+ const permissionName = (kind: CameraPermissionKind) =>
24
+ kind === 'camera' ? 'android.permission.CAMERA' : 'android.permission.RECORD_AUDIO'
25
+
26
+ const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
27
+ new CameraCaptureError({ kind, operation, message, cause })
28
+
29
+ const context = () => Application.android.context
30
+ const preferences = () => context().getSharedPreferences('octane-camera-permissions', 0)
31
+
32
+ async function checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
33
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
34
+ if (!activity) {
35
+ return 'unavailable'
36
+ }
37
+
38
+ const permission = permissionName(kind)
39
+ if (activity.checkSelfPermission(permission) === 0) {
40
+ return 'granted'
41
+ }
42
+
43
+ return preferences().getBoolean(kind, false)
44
+ ? activity.shouldShowRequestPermissionRationale(permission)
45
+ ? 'denied'
46
+ : 'blocked'
47
+ : 'notDetermined'
48
+ }
49
+
50
+ function requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
51
+ const result = permissionChain.then(async () => {
52
+ const status = await checkPermission(kind)
53
+ if (status !== 'notDetermined') {
54
+ return status
55
+ }
56
+
57
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
58
+ if (!activity) {
59
+ return 'unavailable' as const
60
+ }
61
+
62
+ return new Promise<CameraPermissionStatus>((resolve) => {
63
+ const code = requestCode++
64
+ const finish = (value: CameraPermissionStatus) => {
65
+ clearTimeout(timer)
66
+ Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
67
+ resolve(value)
68
+ }
69
+
70
+ const onResult = (args: any) => {
71
+ if (args.requestCode !== code) {
72
+ return
73
+ }
74
+
75
+ if (!args.grantResults?.length) {
76
+ finish('unknown')
77
+ return
78
+ }
79
+
80
+ preferences().edit().putBoolean(kind, true).apply()
81
+ finish(
82
+ args.grantResults[0] === 0
83
+ ? 'granted'
84
+ : activity.shouldShowRequestPermissionRationale(permissionName(kind))
85
+ ? 'denied'
86
+ : 'blocked',
87
+ )
88
+ }
89
+
90
+ const timer = setTimeout(() => finish('unknown'), 120_000)
91
+ Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
92
+ try {
93
+ activity.requestPermissions([permissionName(kind)], code)
94
+ } catch {
95
+ finish('unavailable')
96
+ }
97
+ })
98
+ })
99
+
100
+ permissionChain = result.catch(() => {})
101
+ return result
102
+ }
103
+
104
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
105
+ let currentConfig = { ...config }
106
+ let observer: BackendObserver | undefined
107
+ let bridge: any
108
+ let host: any
109
+ let listener: BackendPreviewListener | undefined
110
+ let sink: BackendCaptureSink | undefined
111
+ let released = false
112
+ const lease = {}
113
+ const selected = () =>
114
+ typeof currentConfig.camera === 'object'
115
+ ? `id:${currentConfig.camera.deviceId}`
116
+ : (currentConfig.camera ?? 'default')
117
+
118
+ const releaseLease = () => {
119
+ if (owner === lease) {
120
+ owner = undefined
121
+ }
122
+ }
123
+
124
+ const onSuspend = () => bridge?.background()
125
+ const onResume = () => bridge?.foreground()
126
+ const ensureBridge = () => {
127
+ if (bridge) {
128
+ return
129
+ }
130
+
131
+ const Bridge = nativeBridge()
132
+ if (!Bridge) {
133
+ throw error(
134
+ 'unsupportedPlatform',
135
+ 'preview',
136
+ 'CameraX video bridge is missing from the native build',
137
+ )
138
+ }
139
+
140
+ bridge = new Bridge(
141
+ context(),
142
+ new Bridge.Listener({
143
+ event(type: string, payload: string) {
144
+ if (type === 'ready') {
145
+ observer?.previewReady(payload === 'true')
146
+ if (payload === 'true') {
147
+ listener?.onReady?.()
148
+ }
149
+ } else if (type === 'availability') {
150
+ observer?.availability(payload === 'true')
151
+ } else if (type === 'interruption') {
152
+ observer?.interruption('began', payload as any)
153
+ } else if (type === 'recovered') {
154
+ observer?.interruption('ended', payload as any)
155
+ } else if (type === 'previewError') {
156
+ observer?.availability(false)
157
+ listener?.onError?.(error('unavailable', 'preview', payload))
158
+ if (!host) {
159
+ releaseLease()
160
+ }
161
+ } else if (type === 'finishing') {
162
+ sink?.finishing(payload ? (payload as any) : undefined)
163
+ } else if (type === 'started') {
164
+ sink?.started(JSON.parse(payload))
165
+ } else if (type === 'finished' || type === 'failed') {
166
+ const target = sink
167
+ sink = undefined
168
+ if (!host) {
169
+ releaseLease()
170
+ }
171
+
172
+ const result = JSON.parse(payload)
173
+ target?.settled(
174
+ type === 'finished'
175
+ ? result
176
+ : {
177
+ kind: 'failed',
178
+ stage: result.stage,
179
+ issue: error(result.kind, result.stage, result.message),
180
+ },
181
+ )
182
+ }
183
+ },
184
+ }),
185
+ )
186
+
187
+ Application.on(Application.suspendEvent, onSuspend)
188
+ Application.on(Application.resumeEvent, onResume)
189
+ }
190
+
191
+ return {
192
+ platform: 'android',
193
+ get supported() {
194
+ return !!nativeBridge()
195
+ },
196
+ checkPermission,
197
+ requestPermission: async (kind) => {
198
+ const status = await requestPermission(kind)
199
+ if (
200
+ kind === 'camera' &&
201
+ status === 'granted' &&
202
+ host &&
203
+ !sink &&
204
+ !released &&
205
+ !bridge?.isReady()
206
+ ) {
207
+ try {
208
+ ensureBridge()
209
+ bridge.attach(host, selected())
210
+ } catch (cause) {
211
+ observer?.availability(false)
212
+ listener?.onError?.(
213
+ error(
214
+ 'unavailable',
215
+ 'preview',
216
+ 'Could not activate CameraX after permission approval',
217
+ cause,
218
+ ),
219
+ )
220
+ }
221
+ }
222
+
223
+ return status
224
+ },
225
+ getCapabilities: async () => {
226
+ const value = bridge ? JSON.parse(bridge.capabilities()) : { cameras: [], profiles: [] }
227
+ const supported = !!nativeBridge()
228
+ return {
229
+ supported,
230
+ available: !!bridge?.isReady(),
231
+ reason: !supported
232
+ ? 'CameraX video bridge is missing from the native build'
233
+ : !bridge?.isReady()
234
+ ? 'An active permitted preview is required'
235
+ : undefined,
236
+ cameras: value.cameras,
237
+ profiles: value.profiles,
238
+ audio: context().getPackageManager().hasSystemFeature('android.hardware.microphone'),
239
+ // The bridge only reports metering support once a camera is
240
+ // bound — absent fields read as unsupported, which is honest
241
+ // for the current acquisition state.
242
+ focusPointOfInterest: value.focusPointOfInterest === true,
243
+ exposurePointOfInterest: value.exposurePointOfInterest === true,
244
+ orientations: [],
245
+ durationLimit: 'native',
246
+ elapsedTime: 'nativeMedia',
247
+ output: {
248
+ mimeType: 'video/mp4',
249
+ container: 'mp4',
250
+ storage: 'appPrivateFile',
251
+ destinationFileUrl: true,
252
+ },
253
+ }
254
+ },
255
+ previewReady: () => !!bridge?.isReady(),
256
+ validateStart: (options) => {
257
+ if (
258
+ options.maximumDurationMs !== undefined &&
259
+ options.maximumDurationMs > 9_223_372_036_854
260
+ ) {
261
+ throw error(
262
+ 'unsupportedConfiguration',
263
+ 'startRecording',
264
+ 'The duration exceeds CameraX media-time range',
265
+ )
266
+ }
267
+
268
+ if (options.orientation && options.orientation !== 'unspecified') {
269
+ throw error(
270
+ 'unsupportedConfiguration',
271
+ 'startRecording',
272
+ 'Fixed cardinal orientation has not been qualified on this Android source',
273
+ )
274
+ }
275
+ },
276
+ attachPreview: (target, callbacks) => {
277
+ if (released || sink) {
278
+ throw error('invalidState', 'attachPreview', 'The camera is disposed or finalizing')
279
+ }
280
+
281
+ if (owner && owner !== lease) {
282
+ throw error('busy', 'attachPreview', 'Another camera session is still finalizing')
283
+ }
284
+
285
+ owner = lease
286
+ host = target
287
+ listener = callbacks
288
+ try {
289
+ ensureBridge()
290
+ } catch (cause) {
291
+ host = undefined
292
+ listener = undefined
293
+ releaseLease()
294
+ throw error(
295
+ 'unavailable',
296
+ 'attachPreview',
297
+ 'Could not construct the CameraX native bridge',
298
+ cause,
299
+ )
300
+ }
301
+
302
+ void checkPermission('camera').then((status) => {
303
+ if (released || !host) {
304
+ return
305
+ }
306
+
307
+ if (status !== 'granted') {
308
+ listener?.onError?.(
309
+ error(
310
+ 'permissionDenied',
311
+ 'preview',
312
+ 'Request camera permission before activating this preview',
313
+ ),
314
+ )
315
+
316
+ return
317
+ }
318
+
319
+ try {
320
+ bridge.attach(host, selected())
321
+ } catch (cause) {
322
+ listener?.onError?.(
323
+ error('unavailable', 'preview', 'Could not bind shared CameraX preview', cause),
324
+ )
325
+ }
326
+ })
327
+
328
+ return () => {
329
+ host = undefined
330
+ listener = undefined
331
+ bridge.detach()
332
+ if (!sink) {
333
+ releaseLease()
334
+ }
335
+ }
336
+ },
337
+ configure: (next) => {
338
+ const sourceChanged =
339
+ JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
340
+ next.profile !== currentConfig.profile
341
+
342
+ currentConfig = { ...next }
343
+ if (host && sourceChanged) {
344
+ bridge.detach()
345
+ bridge.attach(host, selected())
346
+ }
347
+ },
348
+ focusAtPoint: (point) => {
349
+ try {
350
+ bridge?.focusAtPoint?.(point.x, point.y)
351
+ } catch {
352
+ // A missed metering pass must not fault the session.
353
+ }
354
+ },
355
+ startCapture: (request, target) => {
356
+ sink = target
357
+ if (request.destinationPath) {
358
+ // Durable output stays app-private like every other leaf —
359
+ // canonicalize so '..' cannot escape the files root.
360
+ const destination = canonicalizePath(request.destinationPath)
361
+ let filesRoot = ''
362
+ try {
363
+ filesRoot = context()?.getFilesDir?.().getAbsolutePath?.() ?? ''
364
+ } catch {
365
+ filesRoot = ''
366
+ }
367
+
368
+ if (!filesRoot || !destination.startsWith(`${filesRoot}/`)) {
369
+ sink = undefined
370
+ target.settled({
371
+ kind: 'failed',
372
+ stage: 'preparation',
373
+ issue: error(
374
+ 'destinationUnavailable',
375
+ 'startRecording',
376
+ 'destinationFileUrl must resolve inside the app-private files directory',
377
+ ),
378
+ })
379
+
380
+ return
381
+ }
382
+
383
+ request = { ...request, destinationPath: destination }
384
+ }
385
+
386
+ try {
387
+ bridge.start(
388
+ request.audio,
389
+ request.destinationPath ?? '',
390
+ request.maximumDurationMs === undefined
391
+ ? 0
392
+ : Math.max(1, Math.ceil(request.maximumDurationMs)),
393
+ )
394
+ } catch (cause) {
395
+ sink = undefined
396
+ target.settled({
397
+ kind: 'failed',
398
+ stage: 'preparation',
399
+ issue: error(
400
+ 'captureFailed',
401
+ 'startRecording',
402
+ 'CameraX could not begin recording',
403
+ cause,
404
+ ),
405
+ })
406
+ }
407
+ },
408
+ stopCapture: () => bridge?.stop(),
409
+ elapsedMs: () => Number(bridge?.elapsedMs() ?? 0),
410
+ openOutput: async (output) => {
411
+ if (output.kind !== 'nativeFile' || output.synthetic) {
412
+ throw error('invalidArgument', 'openOutput', 'Expected a native Android movie reference')
413
+ }
414
+
415
+ try {
416
+ const url = nativeBridge().openOutput(output.resourceId, output.fileUrl)
417
+ return { url, fileUrl: url, release: () => {} }
418
+ } catch (cause) {
419
+ throw error(
420
+ 'destinationUnavailable',
421
+ 'openOutput',
422
+ 'The stored Android movie cannot be reopened',
423
+ cause,
424
+ )
425
+ }
426
+ },
427
+ release: async () => {
428
+ released = true
429
+ Application.off(Application.suspendEvent, onSuspend)
430
+ Application.off(Application.resumeEvent, onResume)
431
+ bridge?.release()
432
+ host = undefined
433
+ releaseLease()
434
+ },
435
+ setObserver: (value) => {
436
+ observer = value
437
+ },
438
+ }
439
+ }