@octane-xplat/camera 0.14.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 (81) hide show
  1. package/README.md +26 -0
  2. package/package.json +22 -16
  3. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
  4. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
  5. package/platforms/macos/src/CameraOwnership.swift +22 -0
  6. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  7. package/platforms/macos/src/XplatCameraHost.swift +139 -13
  8. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  9. package/src/CameraView.android.tsrx +6 -2
  10. package/src/CameraView.ios.tsrx +4 -2
  11. package/src/CameraView.macos.tsrx +4 -2
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +4 -2
  14. package/src/camera-permission.mobile.ts +18 -4
  15. package/src/camera-session.windows.ts +4 -1
  16. package/src/host-movie.web.ts +141 -26
  17. package/src/index.ts +1 -0
  18. package/src/index.web.ts +1 -0
  19. package/src/preview-tap.ts +34 -0
  20. package/src/props.ts +8 -1
  21. package/src/session-backend.android.ts +42 -0
  22. package/src/session-backend.ios.ts +183 -17
  23. package/src/session-backend.linux.ts +22 -4
  24. package/src/session-backend.macos.ts +100 -17
  25. package/src/session-backend.ts +2 -0
  26. package/src/session-backend.web.ts +17 -0
  27. package/src/session-backend.windows.ts +88 -6
  28. package/src/session-core.ts +73 -9
  29. package/src/synthetic-session.ts +10 -0
  30. package/src/types.ts +28 -0
  31. package/types/props.d.ts +8 -1
  32. package/types/session-core.d.ts +11 -1
  33. package/types/synthetic-session.d.ts +4 -1
  34. package/types/types.d.ts +27 -0
  35. package/dist/linux/CameraView.web.tsrx.js +0 -337
  36. package/dist/linux/host-movie.web.js +0 -100
  37. package/dist/linux/index.js +0 -5
  38. package/dist/linux/layout-child.web.js +0 -29
  39. package/dist/linux/movie-metadata.web.js +0 -65
  40. package/dist/linux/octane-client-build.json +0 -8
  41. package/dist/linux/session-backend.linux.js +0 -479
  42. package/dist/linux/session-core.js +0 -368
  43. package/dist/linux/session.js +0 -29
  44. package/dist/linux/synthetic-session.js +0 -280
  45. package/dist/linux/types.js +0 -19
  46. package/dist/native/CameraView.android.js +0 -190
  47. package/dist/native/CameraView.ios.js +0 -154
  48. package/dist/native/CameraView.windows.js +0 -173
  49. package/dist/native/camera-permission.mobile.js +0 -45
  50. package/dist/native/camera-permission.windows.js +0 -43
  51. package/dist/native/camera-session.ios.js +0 -45
  52. package/dist/native/camera-session.windows.js +0 -78
  53. package/dist/native/cx.mobile.js +0 -9
  54. package/dist/native/cx.windows.js +0 -9
  55. package/dist/native/index.js +0 -5
  56. package/dist/native/layout-child.js +0 -53
  57. package/dist/native/layout-child.windows.js +0 -53
  58. package/dist/native/octane-client-build.json +0 -8
  59. package/dist/native/session-backend.android.js +0 -234
  60. package/dist/native/session-backend.ios.js +0 -682
  61. package/dist/native/session-backend.js +0 -55
  62. package/dist/native/session-backend.windows.js +0 -905
  63. package/dist/native/session-core.js +0 -368
  64. package/dist/native/session.js +0 -29
  65. package/dist/native/synthetic-session.js +0 -280
  66. package/dist/native/types.js +0 -19
  67. package/dist/web/CameraView.web.tsrx.js +0 -337
  68. package/dist/web/host-movie.web.js +0 -100
  69. package/dist/web/index.js +0 -5
  70. package/dist/web/layout-child.web.js +0 -29
  71. package/dist/web/movie-metadata.web.js +0 -65
  72. package/dist/web/movie-storage.web.js +0 -119
  73. package/dist/web/octane-client-build.json +0 -8
  74. package/dist/web/session-backend.web.js +0 -490
  75. package/dist/web/session-core.js +0 -368
  76. package/dist/web/session.js +0 -29
  77. package/dist/web/synthetic-session.js +0 -280
  78. package/dist/web/types.js +0 -19
  79. package/src/cx.macos.ts +0 -5
  80. package/src/cx.mobile.ts +0 -6
  81. package/src/cx.windows.ts +0 -6
@@ -1,5 +1,6 @@
1
1
  import { File, knownFolders, path } from '@nativescript/core'
2
2
  import { CameraCaptureError } from './types'
3
+ import { canonicalizePath } from './session-core'
3
4
  import type {
4
5
  BackendCaptureRequest,
5
6
  BackendCaptureSink,
@@ -20,6 +21,29 @@ import type {
20
21
  MovieOutputHandle,
21
22
  } from './types'
22
23
 
24
+ import {
25
+ AVCaptureDevice,
26
+ AVCaptureDeviceDiscoverySession,
27
+ AVCaptureDeviceInput,
28
+ AVCaptureMovieFileOutput,
29
+ AVCaptureSession,
30
+ AVCaptureVideoPreviewLayer,
31
+ AVURLAsset,
32
+ CGPointMake,
33
+ NSBundle,
34
+ NSError,
35
+ NSFileManager,
36
+ NSNotification,
37
+ NSNotificationCenter,
38
+ NSNumber,
39
+ NSObject,
40
+ NSOperationQueue,
41
+ NSURL,
42
+ } from '@octane-xplat/platform/ios'
43
+
44
+ import { AVCaptureFileOutputRecordingDelegate } from '@octane-xplat/platform/ios'
45
+ import type { CGAffineTransform, NSObjectProtocol } from '@octane-xplat/platform/ios'
46
+
23
47
  // AVAuthorizationStatus numeric values — the ambient const enum cannot be
24
48
  // read under verbatimModuleSyntax in every build lane.
25
49
  const authorizationNotDetermined = 0
@@ -52,6 +76,12 @@ const interruptionVideoDeviceInUseByAnotherClient = 3
52
76
  const interruptionVideoDeviceNotAvailableWithMultipleForegroundApps = 4
53
77
  const interruptionVideoDeviceNotAvailableDueToSystemPressure = 5
54
78
  const avErrorMaximumDurationReached = -11810
79
+ const videoStabilizationModeAuto = -1
80
+
81
+ // AVCaptureFocusMode/AVCaptureExposureMode numeric values — tap-to-focus
82
+ // uses the single-shot modes, matching the AVCam convention.
83
+ const focusModeAutoFocus = 1
84
+ const exposureModeAutoExpose = 1
55
85
 
56
86
  const mediaTypeFor = (kind: CameraPermissionKind): string =>
57
87
  kind === 'camera' ? AVMediaTypeVideo : AVMediaTypeAudio
@@ -212,6 +242,27 @@ const orientationFromTransform = (transform: CGAffineTransform): CameraOrientati
212
242
  return 'unspecified'
213
243
  }
214
244
 
245
+ /** Prefer Apple's automatic stabilization on a capture connection when the
246
+ * hardware offers it; unsupported connections keep the runtime default.
247
+ * `isVideoStabilizationModeSupported:` is missing from the generated {N}
248
+ * typings, so it is invoked dynamically with `supportsVideoStabilization`
249
+ * as the older-runtime fallback. */
250
+ const preferAutoStabilization = (connection: AVCaptureConnection | undefined | null) => {
251
+ if (!connection) {
252
+ return
253
+ }
254
+
255
+ const check = (connection as any).isVideoStabilizationModeSupported
256
+ const supported =
257
+ typeof check === 'function'
258
+ ? Boolean(check.call(connection, videoStabilizationModeAuto))
259
+ : Boolean(connection.supportsVideoStabilization)
260
+
261
+ if (supported) {
262
+ connection.preferredVideoStabilizationMode = videoStabilizationModeAuto
263
+ }
264
+ }
265
+
215
266
  const mapInterruptionReason = (raw: number | undefined): CameraInterruptionReason => {
216
267
  switch (raw) {
217
268
  case interruptionVideoDeviceNotAvailableInBackground:
@@ -248,6 +299,28 @@ const hasUsageDeclaration = (kind: CameraPermissionKind): boolean =>
248
299
 
249
300
  const minFreeDiskBytes = 64 * 1024 * 1024
250
301
 
302
+ /** Values returned through `id`-typed APIs (NSDictionary.objectForKey) arrive
303
+ * already unboxed by the {N} iOS runtime — NSNumber becomes a JS number (or
304
+ * boolean), not an NSNumber instance. Accept either shape so numeric reads
305
+ * stay correct under both conventions. */
306
+ const numericValue = (value: unknown): number | undefined => {
307
+ try {
308
+ if (typeof value === 'number') {
309
+ return value
310
+ }
311
+
312
+ if (typeof value === 'boolean') {
313
+ return value ? 1 : 0
314
+ }
315
+
316
+ const boxed = value as NSNumber | undefined
317
+ const raw = boxed?.longLongValue ?? boxed?.doubleValue
318
+ return typeof raw === 'number' ? raw : undefined
319
+ } catch {
320
+ return undefined
321
+ }
322
+ }
323
+
251
324
  const freeDiskBytes = (): number => {
252
325
  try {
253
326
  const attributes = NSFileManager.defaultManager.attributesOfFileSystemForPathError(
@@ -255,8 +328,7 @@ const freeDiskBytes = (): number => {
255
328
  undefined,
256
329
  )
257
330
 
258
- const free = attributes?.objectForKey(NSFileSystemFreeSize) as unknown as NSNumber | undefined
259
- return free ? Number(free.longLongValue ?? free.doubleValue ?? 0) : Number.MAX_SAFE_INTEGER
331
+ return numericValue(attributes?.objectForKey(NSFileSystemFreeSize)) ?? Number.MAX_SAFE_INTEGER
260
332
  } catch {
261
333
  return Number.MAX_SAFE_INTEGER
262
334
  }
@@ -338,11 +410,9 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
338
410
  }
339
411
 
340
412
  add(AVCaptureSessionWasInterruptedNotification, target, (note) => {
341
- const raw = note.userInfo?.objectForKey(AVCaptureSessionInterruptionReasonKey) as
342
- | NSNumber
343
- | undefined
413
+ const raw = note.userInfo?.objectForKey(AVCaptureSessionInterruptionReasonKey)
344
414
 
345
- notify('began', mapInterruptionReason(raw ? Number(raw.integerValue) : undefined))
415
+ notify('began', mapInterruptionReason(numericValue(raw)))
346
416
  })
347
417
 
348
418
  add(AVCaptureSessionInterruptionEndedNotification, target, () => {
@@ -353,12 +423,21 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
353
423
  observer?.availability(false)
354
424
  })
355
425
 
356
- add(AVCaptureDeviceWasDisconnectedNotification, device, () => {
357
- observer?.availability(false)
426
+ // Filter by the ACTIVE device — `device` is reassigned on
427
+ // configure(), so an observer bound to the captured object would
428
+ // keep reporting on the stale one while the active camera's
429
+ // disconnect went unreported (and any device's connect reported
430
+ // availability(true)).
431
+ add(AVCaptureDeviceWasDisconnectedNotification, null, (note) => {
432
+ if (device && note.object === device) {
433
+ observer?.availability(false)
434
+ }
358
435
  })
359
436
 
360
- add(AVCaptureDeviceWasConnectedNotification, null, () => {
361
- observer?.availability(true)
437
+ add(AVCaptureDeviceWasConnectedNotification, null, (note) => {
438
+ if (device && note.object === device) {
439
+ observer?.availability(true)
440
+ }
362
441
  })
363
442
 
364
443
  add(UIApplicationDidEnterBackgroundNotification, null, () => {
@@ -493,6 +572,18 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
493
572
 
494
573
  session.stopRunning()
495
574
  clearObservers()
575
+ if (interrupted) {
576
+ // The 'ended' notification can be lost when the session is
577
+ // torn down mid-interruption — report it so the flag does not
578
+ // wedge previewReady/startRecording for the next session.
579
+ notify('ended')
580
+ }
581
+
582
+ // A destroyed session's runtime error must not wedge the next
583
+ // one — AVCaptureDeviceWasConnected never fires for built-in
584
+ // cameras, so cameraAvailable would otherwise stay false forever.
585
+ observer?.availability(true)
586
+
496
587
  session = undefined
497
588
  videoInput = undefined
498
589
  audioInput = undefined
@@ -536,7 +627,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
536
627
  const filePath = url?.path ? String(url.path) : ''
537
628
  const succeeded =
538
629
  !error ||
539
- error.userInfo?.objectForKey(AVErrorRecordingSuccessfullyFinishedKey)?.boolValue === true
630
+ numericValue(error.userInfo?.objectForKey(AVErrorRecordingSuccessfullyFinishedKey)) === 1
540
631
 
541
632
  if (!succeeded) {
542
633
  // Clean up only the file this attempt created — never a
@@ -704,6 +795,8 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
704
795
  cameras,
705
796
  profiles: [{ profile: 'standard', ...profile }],
706
797
  audio: typeof AVCaptureDevice.defaultDeviceWithMediaType === 'function',
798
+ focusPointOfInterest: selected?.focusPointOfInterestSupported === true,
799
+ exposurePointOfInterest: selected?.exposurePointOfInterestSupported === true,
707
800
  orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
708
801
  durationLimit: 'native',
709
802
  elapsedTime: 'nativeMedia',
@@ -749,6 +842,8 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
749
842
  if (connection.supportsVideoMirroring) {
750
843
  connection.videoMirrored = device?.position === captureDevicePositionFront
751
844
  }
845
+
846
+ preferAutoStabilization(connection)
752
847
  }
753
848
 
754
849
  nativeView.layer.addSublayer(layer)
@@ -804,6 +899,66 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
804
899
  }
805
900
 
806
901
  target.commitConfiguration()
902
+ // Swapping the video input recreates the preview connection — the
903
+ // stabilization preference must be restated.
904
+ preferAutoStabilization(previewLayer?.connection)
905
+ },
906
+ focusAtPoint: (point) => {
907
+ const captureDevice = device
908
+ const layer = previewLayer
909
+ if (!captureDevice || !layer) {
910
+ return
911
+ }
912
+
913
+ // Front cameras are commonly fixed-focus — meter only the
914
+ // dimensions the device actually supports.
915
+ const focus =
916
+ captureDevice.focusPointOfInterestSupported === true &&
917
+ captureDevice.isFocusModeSupported?.(focusModeAutoFocus) !== false
918
+
919
+ const expose =
920
+ captureDevice.exposurePointOfInterestSupported === true &&
921
+ captureDevice.isExposureModeSupported?.(exposureModeAutoExpose) !== false
922
+
923
+ if (!focus && !expose) {
924
+ return
925
+ }
926
+
927
+ // The preview layer converts view space into device space —
928
+ // aspect-fill cropping, sensor orientation, and the front
929
+ // camera's mirrored connection are all accounted for. Without
930
+ // it there is no honest mapping, so bail rather than meter the
931
+ // wrong point.
932
+ const bounds = layer.bounds
933
+ const layerPoint = CGPointMake(point.x * bounds.size.width, point.y * bounds.size.height)
934
+
935
+ if (typeof layer.captureDevicePointOfInterestForPoint !== 'function') {
936
+ return
937
+ }
938
+
939
+ const devicePoint = layer.captureDevicePointOfInterestForPoint(layerPoint)
940
+
941
+ try {
942
+ if (captureDevice.lockForConfiguration?.(undefined) !== true) {
943
+ return
944
+ }
945
+
946
+ // Single-shot AF/AE at the point — the Apple AVCam
947
+ // tap-to-focus convention; the device locks after metering.
948
+ if (focus) {
949
+ captureDevice.focusPointOfInterest = devicePoint
950
+ captureDevice.focusMode = focusModeAutoFocus
951
+ }
952
+
953
+ if (expose) {
954
+ captureDevice.exposurePointOfInterest = devicePoint
955
+ captureDevice.exposureMode = exposureModeAutoExpose
956
+ }
957
+
958
+ captureDevice.unlockForConfiguration()
959
+ } catch {
960
+ // A missed metering pass must not fault the session.
961
+ }
807
962
  },
808
963
  startCapture: (request, sink) => {
809
964
  const target = session
@@ -922,28 +1077,38 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
922
1077
  if (connection.supportsVideoMirroring) {
923
1078
  connection.videoMirrored = false
924
1079
  }
1080
+
1081
+ preferAutoStabilization(connection)
925
1082
  }
926
1083
 
927
1084
  let filePath: string
928
1085
  if (request.destinationPath) {
929
- const directory = request.destinationPath.slice(0, request.destinationPath.lastIndexOf('/'))
930
- if (!File.exists(directory) || File.exists(request.destinationPath)) {
1086
+ // Durable output stays app-private — canonicalize first so a
1087
+ // '..' segment cannot keep the Documents prefix while
1088
+ // resolving outside it.
1089
+ const destination = canonicalizePath(request.destinationPath)
1090
+ const documents = knownFolders.documents().path
1091
+ const inside = destination.startsWith(`${documents}/`)
1092
+ const directory = destination.slice(0, destination.lastIndexOf('/'))
1093
+ if (!inside || !File.exists(directory) || File.exists(destination)) {
931
1094
  sink.settled({
932
1095
  kind: 'failed',
933
1096
  stage: 'preparation',
934
1097
  issue: emitIssue(
935
1098
  'destinationUnavailable',
936
1099
  'startRecording',
937
- File.exists(request.destinationPath)
938
- ? 'destinationFileUrl already exists'
939
- : 'destinationFileUrl directory does not exist',
1100
+ !inside
1101
+ ? 'destinationFileUrl must resolve inside the app-private documents directory'
1102
+ : File.exists(destination)
1103
+ ? 'destinationFileUrl already exists'
1104
+ : 'destinationFileUrl directory does not exist',
940
1105
  ),
941
1106
  })
942
1107
 
943
1108
  return
944
1109
  }
945
1110
 
946
- filePath = request.destinationPath
1111
+ filePath = destination
947
1112
  } else {
948
1113
  try {
949
1114
  filePath = path.join(
@@ -1070,6 +1235,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1070
1235
  captureRequest = undefined
1071
1236
  captureSink = undefined
1072
1237
  clearObservers()
1238
+ interrupted = false
1073
1239
  try {
1074
1240
  session?.stopRunning()
1075
1241
  } catch {}
@@ -133,6 +133,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
133
133
  observer?.interruption('began', 'background')
134
134
  }
135
135
 
136
+ // Track-caused interruptions (ended/mute) need an explicit 'ended' —
137
+ // a fresh live stream resolves them, and without the event the core
138
+ // keeps interrupted=true until the session is recreated.
139
+ let deviceInterrupted = false
140
+
136
141
  const observeTracks = (target: MediaStream) => {
137
142
  for (const track of target.getTracks()) {
138
143
  track.addEventListener('ended', () => {
@@ -145,6 +150,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
145
150
  }
146
151
 
147
152
  markReady(false)
153
+ deviceInterrupted = true
148
154
  observer?.availability(false)
149
155
  observer?.interruption('began', 'unknown')
150
156
  })
@@ -156,12 +162,14 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
156
162
 
157
163
  if (stream === target) {
158
164
  markReady(false)
165
+ deviceInterrupted = true
159
166
  observer?.interruption('began', 'unknown')
160
167
  }
161
168
  })
162
169
 
163
170
  track.addEventListener('unmute', () => {
164
171
  if (stream === target && document.visibilityState !== 'hidden') {
172
+ deviceInterrupted = false
165
173
  observer?.interruption('ended', 'unknown')
166
174
  observer?.availability(true)
167
175
  markReady(!!host)
@@ -214,6 +222,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
214
222
  stream = acquired
215
223
  known.camera = 'granted'
216
224
  observeTracks(acquired)
225
+ if (deviceInterrupted) {
226
+ deviceInterrupted = false
227
+ observer?.interruption('ended', 'unknown')
228
+ }
229
+
217
230
  observer?.availability(true)
218
231
  } catch (cause) {
219
232
  if (owner === lease) {
@@ -253,6 +266,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
253
266
  stream = acquired
254
267
  previous?.getTracks().forEach((track) => track.stop())
255
268
  observeTracks(acquired)
269
+ if (deviceInterrupted) {
270
+ deviceInterrupted = false
271
+ observer?.interruption('ended', 'unknown')
272
+ }
273
+
256
274
  observer?.availability(true)
257
275
  }
258
276
 
@@ -499,6 +517,9 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
499
517
  ]
500
518
  : [],
501
519
  audio: devices.some((device) => device.kind === 'audioinput'),
520
+ // No point-of-interest metering in the WebKitGTK path.
521
+ focusPointOfInterest: false,
522
+ exposurePointOfInterest: false,
502
523
  orientations: [],
503
524
  durationLimit: 'bestEffort',
504
525
  elapsedTime: 'estimated',
@@ -640,10 +661,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
640
661
  )
641
662
  }
642
663
 
643
- const recordingStream = new MediaStream([
644
- video,
645
- ...(request.audio ? audio : []),
646
- ])
664
+ const recordingStream = new MediaStream([video, ...(request.audio ? audio : [])])
647
665
 
648
666
  reservation = (
649
667
  await reserveHostMovie(desktop, {
@@ -1,6 +1,7 @@
1
1
  import '@nativescript/macos-node-api'
2
2
 
3
3
  import { CameraCaptureError } from './types'
4
+ import { canonicalizePath } from './session-core'
4
5
  import type { BackendCaptureRequest, BackendCaptureSink, BackendObserver } from './session-core'
5
6
  import type { CameraSessionBackend } from './session-core'
6
7
  import type { BackendPreviewListener } from './session-core'
@@ -29,6 +30,9 @@ interface CameraHost {
29
30
  audioInputAttached(): boolean
30
31
  activeDeviceId(): string | null
31
32
  configure(options: { cameraId?: string; audio?: boolean; rotationAngle?: number }): string | null
33
+ /** Optional so a host built before the metering leaf shipped degrades
34
+ * to a no-op instead of a selector error. */
35
+ focusAtPoint?(point: { x: number; y: number }): boolean
32
36
  supportsRotationAngles(): boolean
33
37
  movieDirectory(): string | null
34
38
  startRecording(path: string, maximumDurationMs: number): string | null
@@ -103,7 +107,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
103
107
  let observer: BackendObserver | undefined
104
108
  let captureSink: BackendCaptureSink | undefined
105
109
  let captureRequest: BackendCaptureRequest | undefined
106
- let captureFileUrl = ''
110
+ let captureFilePath = ''
107
111
  let released = false
108
112
 
109
113
  const emitIssue = (
@@ -143,8 +147,29 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
143
147
  }
144
148
  }
145
149
 
146
- const selectedCameraId = (): string | undefined => {
147
- const selection = currentConfig.camera
150
+ /** Point-of-interest metering support on the selected device, read
151
+ * from the same device list `devices()` reports. */
152
+ const pointOfInterestSupport = (): { focus: boolean; exposure: boolean } => {
153
+ try {
154
+ const list = JSON.parse(host.devices()) as {
155
+ id: string
156
+ focusPointOfInterest?: boolean
157
+ exposurePointOfInterest?: boolean
158
+ }[]
159
+
160
+ const selected = selectedCameraId()
161
+ const device = list.find((entry) => entry.id === selected) ?? list[0]
162
+ return {
163
+ focus: device?.focusPointOfInterest === true,
164
+ exposure: device?.exposurePointOfInterest === true,
165
+ }
166
+ } catch {
167
+ return { focus: false, exposure: false }
168
+ }
169
+ }
170
+
171
+ const selectedCameraId = (config: CameraSessionConfig = currentConfig): string | undefined => {
172
+ const selection = config.camera
148
173
  if (selection && typeof selection === 'object' && 'deviceId' in selection) {
149
174
  return selection.deviceId
150
175
  }
@@ -164,7 +189,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
164
189
 
165
190
  switch (event.type) {
166
191
  case 'recordingStarted': {
167
- if (event.fileUrl !== captureFileUrl || !captureSink || !captureRequest) {
192
+ if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
168
193
  return
169
194
  }
170
195
 
@@ -184,7 +209,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
184
209
  return
185
210
  }
186
211
  case 'recordingFinished': {
187
- if (event.fileUrl !== captureFileUrl || !captureSink || !captureRequest) {
212
+ if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
188
213
  return
189
214
  }
190
215
 
@@ -207,6 +232,20 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
207
232
  return
208
233
  }
209
234
 
235
+ if (!(event.clip.durationMs > 0) || !(event.clip.width > 0) || !(event.clip.height > 0)) {
236
+ sink.settled({
237
+ kind: 'failed',
238
+ stage: 'capture',
239
+ issue: emitIssue(
240
+ 'captureFailed',
241
+ 'finalize',
242
+ 'The finalized movie has no usable video media',
243
+ ),
244
+ })
245
+
246
+ return
247
+ }
248
+
210
249
  if (request.audio && !event.clip.hasAudio) {
211
250
  sink.settled({
212
251
  kind: 'failed',
@@ -304,6 +343,22 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
304
343
  const pathExists = (path: string): boolean =>
305
344
  Boolean(NSFileManager.defaultManager.fileExistsAtPath(path))
306
345
 
346
+ // Correlate capture events by path, not serialized URL — Foundation's
347
+ // URL(fileURLWithPath:).absoluteString escapes differently than
348
+ // encodeURIComponent, so URL equality drops real start/finish events.
349
+ const sameFile = (fileUrl: string | undefined, filePath: string): boolean => {
350
+ if (!fileUrl || !filePath) {
351
+ return false
352
+ }
353
+
354
+ const raw = fileUrl.startsWith('file://') ? fileUrl.slice('file://'.length) : fileUrl
355
+ try {
356
+ return decodeURIComponent(raw) === filePath
357
+ } catch {
358
+ return raw === filePath
359
+ }
360
+ }
361
+
307
362
  return {
308
363
  platform: 'macos',
309
364
  get supported() {
@@ -315,6 +370,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
315
370
  const list = devices()
316
371
  const available = list.length > 0
317
372
  const size = previewSize()
373
+ const pointOfInterest = pointOfInterestSupport()
318
374
  return {
319
375
  supported: available,
320
376
  reason: available ? undefined : 'No camera device is available on this host',
@@ -329,6 +385,8 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
329
385
  },
330
386
  ],
331
387
  audio: host.audioDeviceAvailable(),
388
+ focusPointOfInterest: pointOfInterest.focus,
389
+ exposurePointOfInterest: pointOfInterest.exposure,
332
390
  // AVCaptureConnection rotation angles exist on macOS 14+;
333
391
  // earlier hosts cannot lock a take's orientation.
334
392
  orientations: host.supportsRotationAngles()
@@ -369,7 +427,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
369
427
 
370
428
  if (configured) {
371
429
  throw error(
372
- configured === 'permissionDenied' ? 'permissionDenied' : 'unavailable',
430
+ configured === 'busy'
431
+ ? 'busy'
432
+ : configured === 'permissionDenied'
433
+ ? 'permissionDenied'
434
+ : 'unavailable',
373
435
  'attachPreview',
374
436
  `Failed to configure the capture session: ${configured}`,
375
437
  )
@@ -392,14 +454,32 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
392
454
  }
393
455
  },
394
456
  configure: (next) => {
395
- currentConfig = { ...next }
396
457
  const configured = host.configure({
397
- cameraId: selectedCameraId(),
458
+ cameraId: selectedCameraId(next),
398
459
  audio: next.audio ?? false,
399
460
  })
400
461
 
401
- if (configured === 'unavailable') {
402
- throw error('unavailable', 'configure', 'The requested camera device is not available')
462
+ if (configured) {
463
+ throw error(
464
+ configured === 'busy'
465
+ ? 'busy'
466
+ : configured === 'permissionDenied'
467
+ ? 'permissionDenied'
468
+ : configured === 'unsupportedConfiguration'
469
+ ? 'unsupportedConfiguration'
470
+ : 'unavailable',
471
+ 'configure',
472
+ `Failed to configure the capture session: ${configured}`,
473
+ )
474
+ }
475
+
476
+ currentConfig = { ...next }
477
+ },
478
+ focusAtPoint: (point) => {
479
+ try {
480
+ host.focusAtPoint?.({ x: point.x, y: point.y })
481
+ } catch {
482
+ // A missed metering pass must not fault the session.
403
483
  }
404
484
  },
405
485
  startCapture: (request, sink) => {
@@ -501,11 +581,14 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
501
581
 
502
582
  let filePath: string
503
583
  if (request.destinationPath) {
504
- const inside = request.destinationPath.startsWith(`${appPrivateRoot()}/`)
505
- const directory = request.destinationPath.slice(0, request.destinationPath.lastIndexOf('/'))
584
+ // Canonicalize before the prefix check — a '..' segment keeps
585
+ // the root prefix on a raw string while resolving outside it.
586
+ const destination = canonicalizePath(request.destinationPath)
587
+ const inside = destination.startsWith(`${appPrivateRoot()}/`)
588
+ const directory = destination.slice(0, destination.lastIndexOf('/'))
506
589
 
507
590
  const directoryExists = inside && pathExists(directory)
508
- const fileExists = inside && pathExists(request.destinationPath)
591
+ const fileExists = inside && pathExists(destination)
509
592
  if (!inside || !directoryExists || fileExists) {
510
593
  sink.settled({
511
594
  kind: 'failed',
@@ -524,7 +607,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
524
607
  return
525
608
  }
526
609
 
527
- if (!/\.mov$/i.test(request.destinationPath)) {
610
+ if (!/\.mov$/i.test(destination)) {
528
611
  sink.settled({
529
612
  kind: 'failed',
530
613
  stage: 'preparation',
@@ -538,7 +621,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
538
621
  return
539
622
  }
540
623
 
541
- filePath = request.destinationPath
624
+ filePath = destination
542
625
  } else {
543
626
  try {
544
627
  filePath = `${recordingsDirectory()}/clip-${Date.now()}-${Math.random()
@@ -560,7 +643,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
560
643
  }
561
644
  }
562
645
 
563
- captureFileUrl = `file://${filePath.split('/').map(encodeURIComponent).join('/')}`
646
+ captureFilePath = filePath
564
647
 
565
648
  captureSink = sink
566
649
  captureRequest = request
@@ -568,7 +651,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
568
651
  if (failure) {
569
652
  captureSink = undefined
570
653
  captureRequest = undefined
571
- captureFileUrl = ''
654
+ captureFilePath = ''
572
655
  sink.settled({
573
656
  kind: 'failed',
574
657
  stage: 'preparation',
@@ -25,6 +25,8 @@ export function createSessionBackend(_config: CameraSessionConfig): CameraSessio
25
25
  cameras: [],
26
26
  profiles: [],
27
27
  audio: false,
28
+ focusPointOfInterest: false,
29
+ exposurePointOfInterest: false,
28
30
  orientations: [],
29
31
  durationLimit: 'bestEffort',
30
32
  elapsedTime: 'estimated',