@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.
- package/README.md +26 -0
- package/package.json +22 -16
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +139 -13
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +6 -2
- package/src/CameraView.ios.tsrx +4 -2
- package/src/CameraView.macos.tsrx +4 -2
- package/src/CameraView.web.tsrx +11 -0
- package/src/CameraView.windows.tsrx +4 -2
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-session.windows.ts +4 -1
- package/src/host-movie.web.ts +141 -26
- package/src/index.ts +1 -0
- package/src/index.web.ts +1 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +8 -1
- package/src/session-backend.android.ts +42 -0
- package/src/session-backend.ios.ts +183 -17
- package/src/session-backend.linux.ts +22 -4
- package/src/session-backend.macos.ts +100 -17
- package/src/session-backend.ts +2 -0
- package/src/session-backend.web.ts +17 -0
- package/src/session-backend.windows.ts +88 -6
- package/src/session-core.ts +73 -9
- package/src/synthetic-session.ts +10 -0
- package/src/types.ts +28 -0
- package/types/props.d.ts +8 -1
- package/types/session-core.d.ts +11 -1
- package/types/synthetic-session.d.ts +4 -1
- package/types/types.d.ts +27 -0
- package/dist/linux/CameraView.web.tsrx.js +0 -337
- package/dist/linux/host-movie.web.js +0 -100
- package/dist/linux/index.js +0 -5
- package/dist/linux/layout-child.web.js +0 -29
- package/dist/linux/movie-metadata.web.js +0 -65
- package/dist/linux/octane-client-build.json +0 -8
- package/dist/linux/session-backend.linux.js +0 -479
- package/dist/linux/session-core.js +0 -368
- package/dist/linux/session.js +0 -29
- package/dist/linux/synthetic-session.js +0 -280
- package/dist/linux/types.js +0 -19
- package/dist/native/CameraView.android.js +0 -190
- package/dist/native/CameraView.ios.js +0 -154
- package/dist/native/CameraView.windows.js +0 -173
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-permission.windows.js +0 -43
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/camera-session.windows.js +0 -78
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/cx.windows.js +0 -9
- package/dist/native/index.js +0 -5
- package/dist/native/layout-child.js +0 -53
- package/dist/native/layout-child.windows.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/native/session-backend.android.js +0 -234
- package/dist/native/session-backend.ios.js +0 -682
- package/dist/native/session-backend.js +0 -55
- package/dist/native/session-backend.windows.js +0 -905
- package/dist/native/session-core.js +0 -368
- package/dist/native/session.js +0 -29
- package/dist/native/synthetic-session.js +0 -280
- package/dist/native/types.js +0 -19
- package/dist/web/CameraView.web.tsrx.js +0 -337
- package/dist/web/host-movie.web.js +0 -100
- package/dist/web/index.js +0 -5
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/movie-metadata.web.js +0 -65
- package/dist/web/movie-storage.web.js +0 -119
- package/dist/web/octane-client-build.json +0 -8
- package/dist/web/session-backend.web.js +0 -490
- package/dist/web/session-core.js +0 -368
- package/dist/web/session.js +0 -29
- package/dist/web/synthetic-session.js +0 -280
- package/dist/web/types.js +0 -19
- package/src/cx.macos.ts +0 -5
- package/src/cx.mobile.ts +0 -6
- 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
|
-
|
|
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)
|
|
342
|
-
| NSNumber
|
|
343
|
-
| undefined
|
|
413
|
+
const raw = note.userInfo?.objectForKey(AVCaptureSessionInterruptionReasonKey)
|
|
344
414
|
|
|
345
|
-
notify('began', mapInterruptionReason(
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
938
|
-
? 'destinationFileUrl
|
|
939
|
-
:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
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 === '
|
|
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
|
|
402
|
-
throw error(
|
|
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
|
-
|
|
505
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
654
|
+
captureFilePath = ''
|
|
572
655
|
sink.settled({
|
|
573
656
|
kind: 'failed',
|
|
574
657
|
stage: 'preparation',
|
package/src/session-backend.ts
CHANGED
|
@@ -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',
|