@octane-xplat/camera 0.14.0 → 0.16.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 (83) 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 +8 -18
  10. package/src/CameraView.ios.tsrx +4 -17
  11. package/src/CameraView.macos.tsrx +4 -17
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +6 -18
  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/layout-child.ts +39 -18
  20. package/src/layout-child.windows.ts +5 -2
  21. package/src/preview-tap.ts +34 -0
  22. package/src/props.ts +8 -1
  23. package/src/session-backend.android.ts +42 -0
  24. package/src/session-backend.ios.ts +183 -17
  25. package/src/session-backend.linux.ts +22 -4
  26. package/src/session-backend.macos.ts +100 -17
  27. package/src/session-backend.ts +2 -0
  28. package/src/session-backend.web.ts +17 -0
  29. package/src/session-backend.windows.ts +88 -6
  30. package/src/session-core.ts +73 -9
  31. package/src/synthetic-session.ts +10 -0
  32. package/src/types.ts +28 -0
  33. package/types/props.d.ts +8 -1
  34. package/types/session-core.d.ts +11 -1
  35. package/types/synthetic-session.d.ts +4 -1
  36. package/types/types.d.ts +27 -0
  37. package/dist/linux/CameraView.web.tsrx.js +0 -337
  38. package/dist/linux/host-movie.web.js +0 -100
  39. package/dist/linux/index.js +0 -5
  40. package/dist/linux/layout-child.web.js +0 -29
  41. package/dist/linux/movie-metadata.web.js +0 -65
  42. package/dist/linux/octane-client-build.json +0 -8
  43. package/dist/linux/session-backend.linux.js +0 -479
  44. package/dist/linux/session-core.js +0 -368
  45. package/dist/linux/session.js +0 -29
  46. package/dist/linux/synthetic-session.js +0 -280
  47. package/dist/linux/types.js +0 -19
  48. package/dist/native/CameraView.android.js +0 -190
  49. package/dist/native/CameraView.ios.js +0 -154
  50. package/dist/native/CameraView.windows.js +0 -173
  51. package/dist/native/camera-permission.mobile.js +0 -45
  52. package/dist/native/camera-permission.windows.js +0 -43
  53. package/dist/native/camera-session.ios.js +0 -45
  54. package/dist/native/camera-session.windows.js +0 -78
  55. package/dist/native/cx.mobile.js +0 -9
  56. package/dist/native/cx.windows.js +0 -9
  57. package/dist/native/index.js +0 -5
  58. package/dist/native/layout-child.js +0 -53
  59. package/dist/native/layout-child.windows.js +0 -53
  60. package/dist/native/octane-client-build.json +0 -8
  61. package/dist/native/session-backend.android.js +0 -234
  62. package/dist/native/session-backend.ios.js +0 -682
  63. package/dist/native/session-backend.js +0 -55
  64. package/dist/native/session-backend.windows.js +0 -905
  65. package/dist/native/session-core.js +0 -368
  66. package/dist/native/session.js +0 -29
  67. package/dist/native/synthetic-session.js +0 -280
  68. package/dist/native/types.js +0 -19
  69. package/dist/web/CameraView.web.tsrx.js +0 -337
  70. package/dist/web/host-movie.web.js +0 -100
  71. package/dist/web/index.js +0 -5
  72. package/dist/web/layout-child.web.js +0 -29
  73. package/dist/web/movie-metadata.web.js +0 -65
  74. package/dist/web/movie-storage.web.js +0 -119
  75. package/dist/web/octane-client-build.json +0 -8
  76. package/dist/web/session-backend.web.js +0 -490
  77. package/dist/web/session-core.js +0 -368
  78. package/dist/web/session.js +0 -29
  79. package/dist/web/synthetic-session.js +0 -280
  80. package/dist/web/types.js +0 -19
  81. package/src/cx.macos.ts +0 -5
  82. package/src/cx.mobile.ts +0 -6
  83. package/src/cx.windows.ts +0 -6
@@ -134,6 +134,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
134
134
  observer?.interruption('began', 'background')
135
135
  }
136
136
 
137
+ // Track-caused interruptions (ended/mute) need an explicit 'ended' —
138
+ // a fresh live stream resolves them, and without the event the core
139
+ // keeps interrupted=true until the session is recreated.
140
+ let deviceInterrupted = false
141
+
137
142
  const observeTracks = (target: MediaStream) => {
138
143
  for (const track of target.getTracks()) {
139
144
  track.addEventListener('ended', () => {
@@ -146,6 +151,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
146
151
  }
147
152
 
148
153
  markReady(false)
154
+ deviceInterrupted = true
149
155
  observer?.availability(false)
150
156
  observer?.interruption('began', 'unknown')
151
157
  })
@@ -157,12 +163,14 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
157
163
 
158
164
  if (stream === target) {
159
165
  markReady(false)
166
+ deviceInterrupted = true
160
167
  observer?.interruption('began', 'unknown')
161
168
  }
162
169
  })
163
170
 
164
171
  track.addEventListener('unmute', () => {
165
172
  if (stream === target && document.visibilityState !== 'hidden') {
173
+ deviceInterrupted = false
166
174
  observer?.interruption('ended', 'unknown')
167
175
  observer?.availability(true)
168
176
  markReady(!!host)
@@ -219,6 +227,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
219
227
  stream = acquired
220
228
  known.camera = 'granted'
221
229
  observeTracks(acquired)
230
+ if (deviceInterrupted) {
231
+ deviceInterrupted = false
232
+ observer?.interruption('ended', 'unknown')
233
+ }
234
+
222
235
  observer?.availability(true)
223
236
  } catch (cause) {
224
237
  if (owner === lease) {
@@ -498,6 +511,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
498
511
  ]
499
512
  : [],
500
513
  audio: devices.some((device) => device.kind === 'audioinput'),
514
+ // The `pointsOfInterest` track constraint is not wired into
515
+ // the adapter — report honestly rather than fabricate.
516
+ focusPointOfInterest: false,
517
+ exposurePointOfInterest: false,
501
518
  orientations: [],
502
519
  durationLimit: 'bestEffort',
503
520
  elapsedTime: 'estimated',
@@ -1,5 +1,6 @@
1
1
  import { Application } 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,
@@ -533,10 +534,19 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
533
534
  const message = String(args?.Message ?? args?.message ?? 'Camera capture failed')
534
535
  markReady(false)
535
536
  observer?.availability(false)
536
- observer?.interruption('began', /in use|denied|access/i.test(message) ? 'cameraInUse' : 'unknown')
537
+ observer?.interruption(
538
+ 'began',
539
+ /in use|denied|access/i.test(message) ? 'cameraInUse' : 'unknown',
540
+ )
541
+
537
542
  if (sink) {
538
543
  // The pipeline died under a live take — try to finalize the
539
- // partial media instead of discarding it outright.
544
+ // partial media instead of discarding it outright. wantStop
545
+ // makes the early stop stick while
546
+ // StartRecordToStorageFileAsync is still in flight, and the
547
+ // deadline settles the take if that call never resolves.
548
+ wantStop = true
549
+ armSettleDeadline(sink)
540
550
  sink.finishing(/device|available|found/i.test(message) ? 'cameraUnavailable' : 'captureError')
541
551
  void stopRecording()
542
552
  }
@@ -545,6 +555,8 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
545
555
  const onRecordLimitationExceeded = () => {
546
556
  // The OS-level recording limitation ended the take — finalize the
547
557
  // usable media and keep first-cause semantics from the core.
558
+ wantStop = true
559
+ armSettleDeadline(sink)
548
560
  sink?.finishing('interrupted')
549
561
  void stopRecording()
550
562
  }
@@ -850,11 +862,29 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
850
862
  return toPromise(folder.CreateFileAsync(name, collisionGenerateUniqueName))
851
863
  }
852
864
 
853
- const filePath = normalizePath(destinationPath)
865
+ // Durable output stays app-private like every other leaf — resolve
866
+ // against LocalFolder, canonicalizing first so '..' cannot escape it.
867
+ const filePath = canonicalizePath(normalizePath(destinationPath), '\\')
868
+ const localRoot = normalizePath(
869
+ String(storage?.ApplicationData?.Current?.LocalFolder?.Path ?? ''),
870
+ )
871
+
872
+ if (!localRoot || !filePath.toLowerCase().startsWith(`${localRoot.toLowerCase()}\\`)) {
873
+ throw error(
874
+ 'destinationUnavailable',
875
+ 'startRecording',
876
+ 'destinationFileUrl must resolve inside app-private storage',
877
+ )
878
+ }
879
+
854
880
  const directory = filePath.slice(0, filePath.lastIndexOf('\\'))
855
881
  const name = filePath.slice(filePath.lastIndexOf('\\') + 1)
856
882
  if (!directory || !name) {
857
- throw error('destinationUnavailable', 'startRecording', 'destinationFileUrl is not a file path')
883
+ throw error(
884
+ 'destinationUnavailable',
885
+ 'startRecording',
886
+ 'destinationFileUrl is not a file path',
887
+ )
858
888
  }
859
889
 
860
890
  const tempPath = String(storage?.ApplicationData?.Current?.TemporaryFolder?.Path ?? '')
@@ -985,7 +1015,13 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
985
1015
  const durationMs = (await input.computeDuration()) * 1000
986
1016
  const width = await video.getDisplayWidth()
987
1017
  const height = await video.getDisplayHeight()
988
- return { durationMs, width, height, hasAudio, orientation: 'unspecified' as CameraOrientation }
1018
+ return {
1019
+ durationMs,
1020
+ width,
1021
+ height,
1022
+ hasAudio,
1023
+ orientation: 'unspecified' as CameraOrientation,
1024
+ }
989
1025
  } finally {
990
1026
  input.dispose()
991
1027
  }
@@ -1011,6 +1047,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1011
1047
  finalizing = false
1012
1048
  wantStop = false
1013
1049
  recordStartedAt = 0
1050
+ if (settleDeadline !== undefined) {
1051
+ clearTimeout(settleDeadline)
1052
+ }
1053
+
1014
1054
  const file = recordFile
1015
1055
  recordFile = undefined
1016
1056
  void removeFileQuietly(file)
@@ -1018,6 +1058,30 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1018
1058
  teardown()
1019
1059
  }
1020
1060
 
1061
+ // Same 30s settle deadline the web/linux adapters carry — once
1062
+ // finalization starts, a hung native call must still settle the take.
1063
+ let settleDeadline: ReturnType<typeof setTimeout> | undefined
1064
+ const armSettleDeadline = (target: BackendCaptureSink | undefined) => {
1065
+ if (settleDeadline !== undefined) {
1066
+ clearTimeout(settleDeadline)
1067
+ }
1068
+
1069
+ if (!target) {
1070
+ return
1071
+ }
1072
+
1073
+ settleDeadline = setTimeout(() => {
1074
+ if (sink !== target) {
1075
+ return
1076
+ }
1077
+
1078
+ finishWithError(
1079
+ 'finalization',
1080
+ error('finalizationFailed', 'finalize', 'Movie finalization timed out'),
1081
+ )
1082
+ }, 30_000)
1083
+ }
1084
+
1021
1085
  const settleClip = async (file: any) => {
1022
1086
  const target = sink
1023
1087
  const take = request
@@ -1025,6 +1089,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1025
1089
  return
1026
1090
  }
1027
1091
 
1092
+ if (settleDeadline !== undefined) {
1093
+ clearTimeout(settleDeadline)
1094
+ }
1095
+
1028
1096
  try {
1029
1097
  let metadata: Awaited<ReturnType<typeof nativeVideoMetadata>>
1030
1098
  try {
@@ -1108,6 +1176,7 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1108
1176
  }
1109
1177
 
1110
1178
  finalizing = true
1179
+ armSettleDeadline(sink)
1111
1180
  try {
1112
1181
  await toPromise(target.StopRecordAsync())
1113
1182
  } catch (cause) {
@@ -1211,6 +1280,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1211
1280
  cameras,
1212
1281
  profiles: [{ profile: 'standard', ...profile }],
1213
1282
  audio: (await listAudioDevices()) > 0,
1283
+ // WinUI MediaCapture focus regions are not wired into the
1284
+ // adapter — report honestly rather than fabricate.
1285
+ focusPointOfInterest: false,
1286
+ exposurePointOfInterest: false,
1214
1287
  orientations:
1215
1288
  capture && typeof capture.SetRecordRotationAsync === 'function'
1216
1289
  ? ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight']
@@ -1476,7 +1549,12 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1476
1549
  try {
1477
1550
  await toPromise(storage.StorageFile.GetFileFromPathAsync(filePath))
1478
1551
  } catch (cause) {
1479
- throw error('unavailable', 'openOutput', `Movie file is missing at ${output.fileUrl}`, cause)
1552
+ throw error(
1553
+ 'unavailable',
1554
+ 'openOutput',
1555
+ `Movie file is missing at ${output.fileUrl}`,
1556
+ cause,
1557
+ )
1480
1558
  }
1481
1559
 
1482
1560
  const handle: MovieOutputHandle = {
@@ -1491,6 +1569,10 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
1491
1569
  released = true
1492
1570
  generation += 1
1493
1571
  unbindLifecycle()
1572
+ if (settleDeadline !== undefined) {
1573
+ clearTimeout(settleDeadline)
1574
+ }
1575
+
1494
1576
  sink = undefined
1495
1577
  request = undefined
1496
1578
  wantStop = false
@@ -3,6 +3,7 @@ import type {
3
3
  CameraInterruptionReason,
4
4
  CameraPermissionKind,
5
5
  CameraPermissionStatus,
6
+ CameraPreviewPoint,
6
7
  CameraSession,
7
8
  CameraSessionCapabilities,
8
9
  CameraSessionConfig,
@@ -86,6 +87,10 @@ export interface CameraSessionBackend {
86
87
  attachPreview(host: unknown, listener?: BackendPreviewListener): () => void
87
88
  /** Replace configuration while idle; throws CameraCaptureError. */
88
89
  configure?(config: CameraSessionConfig): void
90
+ /** Meter one autofocus/auto-exposure pass at a normalized preview-space
91
+ * point — the tap-to-focus primitive. Absent means the adapter cannot
92
+ * meter at a point; the core turns it into a no-op. */
93
+ focusAtPoint?(point: CameraPreviewPoint): void
89
94
  /** Synchronous platform-specific admission checks; creates no attempt on failure. */
90
95
  validateStart?(options: StartRecordingOptions): void
91
96
  /** Begin capture for an admitted attempt. Async failures settle via
@@ -123,6 +128,34 @@ const issue = (
123
128
  extra?: Partial<CaptureIssue>,
124
129
  ): CameraCaptureError => new CameraCaptureError({ kind, operation, message, ...extra })
125
130
 
131
+ /** Collapse '.' and '..' segments without touching the filesystem — the
132
+ * adapters' app-private containment checks compare against this canonical
133
+ * form, so a '..' inside the requested path can never keep the root
134
+ * prefix while escaping it. */
135
+ export const canonicalizePath = (raw: string, separator = '/'): string => {
136
+ const rooted = raw.startsWith(separator)
137
+ const parts: string[] = []
138
+ for (const part of raw.split(separator)) {
139
+ if (part === '' || part === '.') {
140
+ continue
141
+ }
142
+
143
+ if (part === '..') {
144
+ if (parts.length > 0 && parts[parts.length - 1] !== '..') {
145
+ parts.pop()
146
+ } else if (!rooted) {
147
+ parts.push(part)
148
+ }
149
+
150
+ continue
151
+ }
152
+
153
+ parts.push(part)
154
+ }
155
+
156
+ return (rooted ? separator : '') + parts.join(separator)
157
+ }
158
+
126
159
  /** The concrete session object wrapping a platform backend. Internal to
127
160
  * the package; `CameraSession` is the public shape. */
128
161
  export class SharedCameraSession implements CameraSession {
@@ -283,7 +316,14 @@ export class SharedCameraSession implements CameraSession {
283
316
  return status
284
317
  })
285
318
 
286
- this.requestChains.set(kind, chain)
319
+ // Store a swallowed continuation — a rejected backend call must not
320
+ // poison the chain and refuse every later request with the original
321
+ // error. The caller still receives the real rejection.
322
+ this.requestChains.set(
323
+ kind,
324
+ chain.catch(() => 'unknown' as CameraPermissionStatus),
325
+ )
326
+
287
327
  return chain
288
328
  }
289
329
 
@@ -293,6 +333,21 @@ export class SharedCameraSession implements CameraSession {
293
333
  return capabilities
294
334
  }
295
335
 
336
+ focusAtPoint(point: CameraPreviewPoint): void {
337
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
338
+ throw issue('invalidArgument', 'focusAtPoint', 'point.x and point.y must be finite numbers')
339
+ }
340
+
341
+ if (this.disposed || this.disposeRequested) {
342
+ return
343
+ }
344
+
345
+ this.backend.focusAtPoint?.({
346
+ x: Math.min(1, Math.max(0, point.x)),
347
+ y: Math.min(1, Math.max(0, point.y)),
348
+ })
349
+ }
350
+
296
351
  async configure(next: Partial<CameraSessionConfig>): Promise<void> {
297
352
  if (this.disposed || this.disposeRequested) {
298
353
  throw issue('invalidState', 'configure', 'CameraSession is disposed')
@@ -414,11 +469,22 @@ export class SharedCameraSession implements CameraSession {
414
469
  }
415
470
  }
416
471
 
417
- if (
418
- options.destinationFileUrl !== undefined &&
419
- !options.destinationFileUrl.startsWith('file://')
420
- ) {
421
- throw issue('invalidArgument', operation, 'destinationFileUrl must be a local file:// URL')
472
+ let destinationPath: string | undefined
473
+ if (options.destinationFileUrl !== undefined) {
474
+ if (!options.destinationFileUrl.startsWith('file://')) {
475
+ throw issue('invalidArgument', operation, 'destinationFileUrl must be a local file:// URL')
476
+ }
477
+
478
+ try {
479
+ destinationPath = decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
480
+ } catch (cause) {
481
+ throw issue(
482
+ 'invalidArgument',
483
+ operation,
484
+ 'destinationFileUrl is not valid percent-encoding',
485
+ { cause },
486
+ )
487
+ }
422
488
  }
423
489
 
424
490
  if (
@@ -577,9 +643,7 @@ export class SharedCameraSession implements CameraSession {
577
643
  audio: this.config.audio === true,
578
644
  orientation: options.orientation,
579
645
  maximumDurationMs: options.maximumDurationMs,
580
- destinationPath: options.destinationFileUrl?.startsWith('file://')
581
- ? decodeURIComponent(options.destinationFileUrl.slice('file://'.length))
582
- : undefined,
646
+ destinationPath,
583
647
  }
584
648
 
585
649
  try {
@@ -10,6 +10,7 @@ import type {
10
10
  import type {
11
11
  CameraInterruptionReason,
12
12
  CameraPermissionStatus,
13
+ CameraPreviewPoint,
13
14
  CameraSession,
14
15
  CameraSessionCapabilities,
15
16
  CameraSessionConfig,
@@ -47,6 +48,9 @@ export interface SyntheticCameraScript {
47
48
  reason: CameraInterruptionReason
48
49
  endsAfterMs?: number
49
50
  }
51
+ /** When set, each `focusAtPoint` call pushes its normalized point
52
+ * here so a spec can observe metering requests. */
53
+ focusRequests?: CameraPreviewPoint[]
50
54
  }
51
55
 
52
56
  export interface SyntheticCameraStorage {
@@ -92,6 +96,9 @@ const capabilities: CameraSessionCapabilities = {
92
96
  ],
93
97
  profiles: [{ profile: 'standard', width: 1280, height: 720, frameRate: 30 }],
94
98
  audio: true,
99
+ // The seam accepts metering points so tests exercise the supported path.
100
+ focusPointOfInterest: true,
101
+ exposurePointOfInterest: true,
95
102
  orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
96
103
  durationLimit: 'native',
97
104
  elapsedTime: 'nativeMedia',
@@ -238,6 +245,9 @@ function createSyntheticBackend(
238
245
  reason: script.available ? undefined : 'Synthetic camera is unavailable',
239
246
  }),
240
247
  previewReady: () => attached && script.available,
248
+ focusAtPoint: (point) => {
249
+ script.focusRequests?.push(point)
250
+ },
241
251
  attachPreview: (_host, listener) => {
242
252
  attached = true
243
253
  queueMicrotask(() => {
package/src/types.ts CHANGED
@@ -79,6 +79,16 @@ export class CameraCaptureError extends Error implements CaptureIssue {
79
79
 
80
80
  export type CameraFacing = 'front' | 'back'
81
81
 
82
+ /** A normalized position on the live preview as the user sees it:
83
+ * `{x: 0, y: 0}` is the top-left corner and `{x: 1, y: 1}` is
84
+ * bottom-right, independent of sensor orientation and front-camera
85
+ * mirroring. The `CameraView` `onTap` event and
86
+ * `CameraSession.focusAtPoint` share this space. */
87
+ export interface CameraPreviewPoint {
88
+ x: number
89
+ y: number
90
+ }
91
+
82
92
  export type CameraOrientation =
83
93
  | 'portrait'
84
94
  | 'portraitUpsideDown'
@@ -130,6 +140,13 @@ export interface CameraSessionCapabilities {
130
140
  profiles: CameraProfileCapability[]
131
141
  /** The session can add an audio track when permission allows. */
132
142
  audio: boolean
143
+ /** `focusAtPoint` can move autofocus metering on the active camera —
144
+ * false on fixed-focus devices (common for front cameras) and on
145
+ * platforms without point-of-interest metering. */
146
+ focusPointOfInterest: boolean
147
+ /** `focusAtPoint` can move auto-exposure metering on the active
148
+ * camera. */
149
+ exposurePointOfInterest: boolean
133
150
  /** Fixed orientations the source can lock for a take. */
134
151
  orientations: CameraOrientation[]
135
152
  /** `native` enforcement can still overshoot at media boundaries;
@@ -314,6 +331,17 @@ export interface CameraSession {
314
331
  * subscribing late — `completion` is authoritative. Listener
315
332
  * exceptions do not change capture. */
316
333
  subscribe(listener: (event: CameraSessionEvent) => void): () => void
334
+ /** Tap-to-focus: meters one autofocus/auto-exposure pass at a
335
+ * normalized preview-space point (see `CameraPreviewPoint`), pairing
336
+ * with the `CameraView` `onTap` event. No-ops while no preview is
337
+ * attached, after `dispose`, and on hosts without point-of-interest
338
+ * metering — `capabilities()` reports `focusPointOfInterest` and
339
+ * `exposurePointOfInterest` separately. Out-of-range coordinates
340
+ * clamp to the preview edge; non-finite coordinates throw
341
+ * `invalidArgument`. Metering is per-device state: a camera swap
342
+ * through `configure` returns focus/exposure to the new device's
343
+ * default. */
344
+ focusAtPoint(point: CameraPreviewPoint): void
317
345
  /** Replaces configuration while idle. Validates against current
318
346
  * capabilities and rejects with `invalidState` during an attempt. */
319
347
  configure(config: Partial<CameraSessionConfig>): Promise<void>
package/types/props.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Octane } from 'octane/jsx-runtime';
2
- import type { CameraSession } from './types';
2
+ import type { CameraPreviewPoint, CameraSession } from './types';
3
3
  export type CameraAccessibilityRole = 'button' | 'link' | 'search' | 'image' | 'heading' | 'adjustable' | 'summary' | 'text' | 'none' | 'progressbar' | 'checkbox' | 'switch' | 'radio' | 'spinbutton' | 'tab';
4
4
  export interface CameraViewHandle {
5
5
  /** The native preview surface (`HTMLVideoElement` on web). */
@@ -44,6 +44,13 @@ export interface CameraViewProps {
44
44
  onError?: (error: {
45
45
  message?: string;
46
46
  }) => void;
47
+ /** Notification-only tap event — fires with the normalized
48
+ * preview-space position (`CameraPreviewPoint`) and never consumes
49
+ * the gesture, so recognizers layered above the preview (double-tap,
50
+ * pan) still run. Pair with `session.focusAtPoint` for tap-to-focus.
51
+ * Delivered on iOS, Android, and web; desktop runtimes do not dispatch
52
+ * it yet. */
53
+ onTap?: (point: CameraPreviewPoint) => void;
47
54
  /** Native preview surface for platform-specific controls. */
48
55
  ref?: Octane.Ref<CameraViewHandle>;
49
56
  /** Platform-specific properties applied to the preview surface. */
@@ -1,4 +1,4 @@
1
- import type { CameraInterruptionReason, CameraPermissionKind, CameraPermissionStatus, CameraSession, CameraSessionCapabilities, CameraSessionConfig, CameraSessionEvent, CameraSessionSnapshot, CaptureIssue, EffectiveCaptureConfig, MovieEndReason, MovieOutput, MovieOutputHandle, MovieTake, StartRecordingOptions } from './types';
1
+ import type { CameraInterruptionReason, CameraPermissionKind, CameraPermissionStatus, CameraPreviewPoint, CameraSession, CameraSessionCapabilities, CameraSessionConfig, CameraSessionEvent, CameraSessionSnapshot, CaptureIssue, EffectiveCaptureConfig, MovieEndReason, MovieOutput, MovieOutputHandle, MovieTake, StartRecordingOptions } from './types';
2
2
  /** Result an adapter reports when a capture attempt settles. Media
3
3
  * metadata is already read from the finalized movie by the adapter. */
4
4
  export type BackendSettle = {
@@ -60,6 +60,10 @@ export interface CameraSessionBackend {
60
60
  attachPreview(host: unknown, listener?: BackendPreviewListener): () => void;
61
61
  /** Replace configuration while idle; throws CameraCaptureError. */
62
62
  configure?(config: CameraSessionConfig): void;
63
+ /** Meter one autofocus/auto-exposure pass at a normalized preview-space
64
+ * point — the tap-to-focus primitive. Absent means the adapter cannot
65
+ * meter at a point; the core turns it into a no-op. */
66
+ focusAtPoint?(point: CameraPreviewPoint): void;
63
67
  /** Synchronous platform-specific admission checks; creates no attempt on failure. */
64
68
  validateStart?(options: StartRecordingOptions): void;
65
69
  /** Begin capture for an admitted attempt. Async failures settle via
@@ -86,6 +90,11 @@ export interface BackendObserver {
86
90
  /** Preview became ready/unready (session running state changed). */
87
91
  previewReady(ready: boolean): void;
88
92
  }
93
+ /** Collapse '.' and '..' segments without touching the filesystem — the
94
+ * adapters' app-private containment checks compare against this canonical
95
+ * form, so a '..' inside the requested path can never keep the root
96
+ * prefix while escaping it. */
97
+ export declare const canonicalizePath: (raw: string, separator?: string) => string;
89
98
  /** The concrete session object wrapping a platform backend. Internal to
90
99
  * the package; `CameraSession` is the public shape. */
91
100
  export declare class SharedCameraSession implements CameraSession {
@@ -117,6 +126,7 @@ export declare class SharedCameraSession implements CameraSession {
117
126
  }>;
118
127
  requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus>;
119
128
  capabilities(): Promise<CameraSessionCapabilities>;
129
+ focusAtPoint(point: CameraPreviewPoint): void;
120
130
  configure(next: Partial<CameraSessionConfig>): Promise<void>;
121
131
  private validateCameraSelection;
122
132
  private requestStop;
@@ -1,4 +1,4 @@
1
- import type { CameraInterruptionReason, CameraPermissionStatus, CameraSession, CameraSessionConfig } from './types';
1
+ import type { CameraInterruptionReason, CameraPermissionStatus, CameraPreviewPoint, CameraSession, CameraSessionConfig } from './types';
2
2
  /** Development/testing seam — a deterministic camera source that drives
3
3
  * the shared session state machine without hardware. NOT a production
4
4
  * recording source: outcomes carry `synthetic: true` on their output so
@@ -30,6 +30,9 @@ export interface SyntheticCameraScript {
30
30
  reason: CameraInterruptionReason;
31
31
  endsAfterMs?: number;
32
32
  };
33
+ /** When set, each `focusAtPoint` call pushes its normalized point
34
+ * here so a spec can observe metering requests. */
35
+ focusRequests?: CameraPreviewPoint[];
33
36
  }
34
37
  export interface SyntheticCameraStorage {
35
38
  write(path: string, bytes: Uint8Array): void | Promise<void>;
package/types/types.d.ts CHANGED
@@ -43,6 +43,15 @@ export declare class CameraCaptureError extends Error implements CaptureIssue {
43
43
  });
44
44
  }
45
45
  export type CameraFacing = 'front' | 'back';
46
+ /** A normalized position on the live preview as the user sees it:
47
+ * `{x: 0, y: 0}` is the top-left corner and `{x: 1, y: 1}` is
48
+ * bottom-right, independent of sensor orientation and front-camera
49
+ * mirroring. The `CameraView` `onTap` event and
50
+ * `CameraSession.focusAtPoint` share this space. */
51
+ export interface CameraPreviewPoint {
52
+ x: number;
53
+ y: number;
54
+ }
46
55
  export type CameraOrientation = 'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight' | 'unspecified';
47
56
  /** Camera selection for `CameraSessionConfig.camera`. Desktop and browser
48
57
  * hosts expose device identities instead of front/back semantics. */
@@ -86,6 +95,13 @@ export interface CameraSessionCapabilities {
86
95
  profiles: CameraProfileCapability[];
87
96
  /** The session can add an audio track when permission allows. */
88
97
  audio: boolean;
98
+ /** `focusAtPoint` can move autofocus metering on the active camera —
99
+ * false on fixed-focus devices (common for front cameras) and on
100
+ * platforms without point-of-interest metering. */
101
+ focusPointOfInterest: boolean;
102
+ /** `focusAtPoint` can move auto-exposure metering on the active
103
+ * camera. */
104
+ exposurePointOfInterest: boolean;
89
105
  /** Fixed orientations the source can lock for a take. */
90
106
  orientations: CameraOrientation[];
91
107
  /** `native` enforcement can still overshoot at media boundaries;
@@ -248,6 +264,17 @@ export interface CameraSession {
248
264
  * subscribing late — `completion` is authoritative. Listener
249
265
  * exceptions do not change capture. */
250
266
  subscribe(listener: (event: CameraSessionEvent) => void): () => void;
267
+ /** Tap-to-focus: meters one autofocus/auto-exposure pass at a
268
+ * normalized preview-space point (see `CameraPreviewPoint`), pairing
269
+ * with the `CameraView` `onTap` event. No-ops while no preview is
270
+ * attached, after `dispose`, and on hosts without point-of-interest
271
+ * metering — `capabilities()` reports `focusPointOfInterest` and
272
+ * `exposurePointOfInterest` separately. Out-of-range coordinates
273
+ * clamp to the preview edge; non-finite coordinates throw
274
+ * `invalidArgument`. Metering is per-device state: a camera swap
275
+ * through `configure` returns focus/exposure to the new device's
276
+ * default. */
277
+ focusAtPoint(point: CameraPreviewPoint): void;
251
278
  /** Replaces configuration while idle. Validates against current
252
279
  * capabilities and rejects with `invalidState` during an attempt. */
253
280
  configure(config: Partial<CameraSessionConfig>): Promise<void>;