@octane-xplat/camera 0.0.0-nightly.g2e22171
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/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +104 -0
- package/platforms/android/AndroidManifest.xml +6 -0
- package/platforms/android/include.gradle +14 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +10 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +639 -0
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +150 -0
- package/src/CameraView.ios.tsrx +102 -0
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.mobile.ts +6 -0
- package/src/CameraView.web.tsrx +132 -0
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +91 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.ios.ts +65 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +126 -0
- package/src/host-movie.web.ts +295 -0
- package/src/index.ts +36 -0
- package/src/index.web.ts +34 -0
- package/src/layout-child.ts +81 -0
- package/src/layout-child.web.ts +66 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +77 -0
- package/src/session-backend.android.ts +439 -0
- package/src/session-backend.ios.ts +1258 -0
- package/src/session-backend.linux.ts +887 -0
- package/src/session-backend.macos.ts +707 -0
- package/src/session-backend.ts +59 -0
- package/src/session-backend.web.ts +891 -0
- package/src/session-backend.windows.ts +1599 -0
- package/src/session-core.ts +723 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +377 -0
- package/src/types.ts +357 -0
- package/types/index.d.ts +48 -0
- package/types/props.d.ts +61 -0
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
import { File, knownFolders, path } from '@nativescript/core'
|
|
2
|
+
import { CameraCaptureError } from './types'
|
|
3
|
+
import { canonicalizePath } from './session-core'
|
|
4
|
+
import type {
|
|
5
|
+
BackendCaptureRequest,
|
|
6
|
+
BackendCaptureSink,
|
|
7
|
+
BackendObserver,
|
|
8
|
+
BackendPreviewListener,
|
|
9
|
+
CameraSessionBackend,
|
|
10
|
+
} from './session-core'
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
CameraDeviceInfo,
|
|
14
|
+
CameraInterruptionReason,
|
|
15
|
+
CameraOrientation,
|
|
16
|
+
CameraPermissionKind,
|
|
17
|
+
CameraPermissionStatus,
|
|
18
|
+
CaptureIssue,
|
|
19
|
+
CameraSessionConfig,
|
|
20
|
+
EffectiveCaptureConfig,
|
|
21
|
+
MovieOutputHandle,
|
|
22
|
+
} from './types'
|
|
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
|
+
|
|
47
|
+
// AVAuthorizationStatus numeric values — the ambient const enum cannot be
|
|
48
|
+
// read under verbatimModuleSyntax in every build lane.
|
|
49
|
+
const authorizationNotDetermined = 0
|
|
50
|
+
const authorizationRestricted = 1
|
|
51
|
+
const authorizationDenied = 2
|
|
52
|
+
const authorizationAuthorized = 3
|
|
53
|
+
|
|
54
|
+
// CoreMedia sits outside the common NativeScript iOS ambient set — declare
|
|
55
|
+
// the handful of C functions this adapter needs instead of referencing the
|
|
56
|
+
// full typings.
|
|
57
|
+
declare function CMTimeGetSeconds(time: any): number
|
|
58
|
+
declare function CMTimeMakeWithSeconds(seconds: number, preferredTimescale: number): any
|
|
59
|
+
declare function CMVideoFormatDescriptionGetDimensions(videoDesc: any): {
|
|
60
|
+
width: number
|
|
61
|
+
height: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Ambient const enums cannot be read under verbatimModuleSyntax — keep the
|
|
65
|
+
// numeric values here with their Objective-C names.
|
|
66
|
+
const captureDevicePositionUnspecified = 0
|
|
67
|
+
const captureDevicePositionBack = 1
|
|
68
|
+
const captureDevicePositionFront = 2
|
|
69
|
+
const videoOrientationPortrait = 1
|
|
70
|
+
const videoOrientationPortraitUpsideDown = 2
|
|
71
|
+
const videoOrientationLandscapeRight = 3
|
|
72
|
+
const videoOrientationLandscapeLeft = 4
|
|
73
|
+
const interruptionVideoDeviceNotAvailableInBackground = 1
|
|
74
|
+
const interruptionAudioDeviceInUseByAnotherClient = 2
|
|
75
|
+
const interruptionVideoDeviceInUseByAnotherClient = 3
|
|
76
|
+
const interruptionVideoDeviceNotAvailableWithMultipleForegroundApps = 4
|
|
77
|
+
const interruptionVideoDeviceNotAvailableDueToSystemPressure = 5
|
|
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
|
|
85
|
+
|
|
86
|
+
const mediaTypeFor = (kind: CameraPermissionKind): string =>
|
|
87
|
+
kind === 'camera' ? AVMediaTypeVideo : AVMediaTypeAudio
|
|
88
|
+
|
|
89
|
+
const usageKeyFor = (kind: CameraPermissionKind): string =>
|
|
90
|
+
kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'
|
|
91
|
+
|
|
92
|
+
const readPermission = (kind: CameraPermissionKind): CameraPermissionStatus => {
|
|
93
|
+
const status = AVCaptureDevice.authorizationStatusForMediaType(
|
|
94
|
+
mediaTypeFor(kind),
|
|
95
|
+
) as unknown as number
|
|
96
|
+
|
|
97
|
+
switch (status) {
|
|
98
|
+
case authorizationAuthorized:
|
|
99
|
+
return 'granted'
|
|
100
|
+
case authorizationDenied:
|
|
101
|
+
return 'denied'
|
|
102
|
+
case authorizationRestricted:
|
|
103
|
+
return 'restricted'
|
|
104
|
+
case authorizationNotDetermined:
|
|
105
|
+
return 'notDetermined'
|
|
106
|
+
default:
|
|
107
|
+
return 'unknown'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const requestAccess = (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
112
|
+
const declaration = NSBundle.mainBundle.objectForInfoDictionaryKey(usageKeyFor(kind))
|
|
113
|
+
if (!declaration) {
|
|
114
|
+
// Requesting access without the usage-description key crashes the
|
|
115
|
+
// host app — refuse before reaching AVFoundation.
|
|
116
|
+
return Promise.reject(
|
|
117
|
+
new CameraCaptureError({
|
|
118
|
+
kind: 'configurationMissing',
|
|
119
|
+
operation: 'requestPermission',
|
|
120
|
+
permission: kind,
|
|
121
|
+
message: `Missing ${usageKeyFor(kind)} in the host app's Info.plist`,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const status = readPermission(kind)
|
|
127
|
+
if (status !== 'notDetermined') {
|
|
128
|
+
// Repeated requests must not pretend to show a prompt the system
|
|
129
|
+
// will not display.
|
|
130
|
+
return Promise.resolve(status)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new Promise<CameraPermissionStatus>((resolve) => {
|
|
134
|
+
AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(mediaTypeFor(kind), () =>
|
|
135
|
+
resolve(readPermission(kind)),
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const facingForPosition = (position: number): 'front' | 'back' | undefined =>
|
|
141
|
+
position === captureDevicePositionFront
|
|
142
|
+
? 'front'
|
|
143
|
+
: position === captureDevicePositionBack
|
|
144
|
+
? 'back'
|
|
145
|
+
: undefined
|
|
146
|
+
|
|
147
|
+
const listCameras = (): AVCaptureDevice[] => {
|
|
148
|
+
const types = [
|
|
149
|
+
AVCaptureDeviceTypeBuiltInWideAngleCamera,
|
|
150
|
+
AVCaptureDeviceTypeBuiltInUltraWideCamera,
|
|
151
|
+
AVCaptureDeviceTypeBuiltInTelephotoCamera,
|
|
152
|
+
AVCaptureDeviceTypeBuiltInDualCamera,
|
|
153
|
+
AVCaptureDeviceTypeBuiltInDualWideCamera,
|
|
154
|
+
AVCaptureDeviceTypeBuiltInTripleCamera,
|
|
155
|
+
AVCaptureDeviceTypeBuiltInTrueDepthCamera,
|
|
156
|
+
].filter((entry) => typeof entry === 'string')
|
|
157
|
+
|
|
158
|
+
const discovery =
|
|
159
|
+
AVCaptureDeviceDiscoverySession.discoverySessionWithDeviceTypesMediaTypePosition(
|
|
160
|
+
types,
|
|
161
|
+
AVMediaTypeVideo,
|
|
162
|
+
captureDevicePositionUnspecified,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const devices: AVCaptureDevice[] = []
|
|
166
|
+
const found = discovery?.devices
|
|
167
|
+
const count = found?.count ?? 0
|
|
168
|
+
for (let index = 0; index < count; index++) {
|
|
169
|
+
devices.push(found.objectAtIndex(index))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!devices.length) {
|
|
173
|
+
// Older runtime or no discovery support — fall back to the flat
|
|
174
|
+
// device list so simulator-style "no camera" stays honest.
|
|
175
|
+
const legacy = AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo)
|
|
176
|
+
for (let index = 0; index < (legacy?.count ?? 0); index++) {
|
|
177
|
+
devices.push(legacy.objectAtIndex(index))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return devices
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const selectDevice = (
|
|
185
|
+
camera: CameraSessionConfig['camera'],
|
|
186
|
+
devices: AVCaptureDevice[],
|
|
187
|
+
): AVCaptureDevice | undefined => {
|
|
188
|
+
if (camera && typeof camera === 'object' && 'deviceId' in camera) {
|
|
189
|
+
return devices.find((device) => device.uniqueID === camera.deviceId)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (camera === 'front' || camera === 'back') {
|
|
193
|
+
const position = camera === 'front' ? captureDevicePositionFront : captureDevicePositionBack
|
|
194
|
+
return devices.find((device) => device.position === position)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return devices.find((device) => device.position === captureDevicePositionBack) ?? devices[0]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const orientationToRotationAngle = (orientation: CameraOrientation): number | undefined =>
|
|
201
|
+
orientation === 'portrait'
|
|
202
|
+
? 90
|
|
203
|
+
: orientation === 'portraitUpsideDown'
|
|
204
|
+
? 270
|
|
205
|
+
: orientation === 'landscapeLeft'
|
|
206
|
+
? 180
|
|
207
|
+
: orientation === 'landscapeRight'
|
|
208
|
+
? 0
|
|
209
|
+
: undefined
|
|
210
|
+
|
|
211
|
+
const orientationToLegacyConstant = (orientation: CameraOrientation): number | undefined =>
|
|
212
|
+
orientation === 'portrait'
|
|
213
|
+
? videoOrientationPortrait
|
|
214
|
+
: orientation === 'portraitUpsideDown'
|
|
215
|
+
? videoOrientationPortraitUpsideDown
|
|
216
|
+
: orientation === 'landscapeLeft'
|
|
217
|
+
? videoOrientationLandscapeLeft
|
|
218
|
+
: orientation === 'landscapeRight'
|
|
219
|
+
? videoOrientationLandscapeRight
|
|
220
|
+
: undefined
|
|
221
|
+
|
|
222
|
+
/** Derive the cardinal capture orientation from a video track's
|
|
223
|
+
* preferred transform; `undefined` when it cannot be told honestly. */
|
|
224
|
+
const orientationFromTransform = (transform: CGAffineTransform): CameraOrientation => {
|
|
225
|
+
const { a, b, c, d } = transform
|
|
226
|
+
if (a === 0 && b === 1 && c === -1 && d === 0) {
|
|
227
|
+
return 'portrait'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (a === 0 && b === -1 && c === 1 && d === 0) {
|
|
231
|
+
return 'portraitUpsideDown'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (a === 1 && b === 0 && c === 0 && d === 1) {
|
|
235
|
+
return 'landscapeRight'
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (a === -1 && b === 0 && c === 0 && d === -1) {
|
|
239
|
+
return 'landscapeLeft'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return 'unspecified'
|
|
243
|
+
}
|
|
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
|
+
|
|
266
|
+
const mapInterruptionReason = (raw: number | undefined): CameraInterruptionReason => {
|
|
267
|
+
switch (raw) {
|
|
268
|
+
case interruptionVideoDeviceNotAvailableInBackground:
|
|
269
|
+
return 'background'
|
|
270
|
+
case interruptionAudioDeviceInUseByAnotherClient:
|
|
271
|
+
return 'audioInUse'
|
|
272
|
+
case interruptionVideoDeviceInUseByAnotherClient:
|
|
273
|
+
return 'cameraInUse'
|
|
274
|
+
case interruptionVideoDeviceNotAvailableWithMultipleForegroundApps:
|
|
275
|
+
return 'multitasking'
|
|
276
|
+
case interruptionVideoDeviceNotAvailableDueToSystemPressure:
|
|
277
|
+
return 'systemPressure'
|
|
278
|
+
default:
|
|
279
|
+
return 'unknown'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const recordingsDirectory = (): string => {
|
|
284
|
+
const directory = path.join(knownFolders.documents().path, 'octane-camera')
|
|
285
|
+
if (!File.exists(directory)) {
|
|
286
|
+
NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
|
|
287
|
+
directory,
|
|
288
|
+
true,
|
|
289
|
+
null,
|
|
290
|
+
undefined,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return directory
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const hasUsageDeclaration = (kind: CameraPermissionKind): boolean =>
|
|
298
|
+
Boolean(NSBundle.mainBundle.objectForInfoDictionaryKey(usageKeyFor(kind)))
|
|
299
|
+
|
|
300
|
+
const minFreeDiskBytes = 64 * 1024 * 1024
|
|
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
|
+
|
|
324
|
+
const freeDiskBytes = (): number => {
|
|
325
|
+
try {
|
|
326
|
+
const attributes = NSFileManager.defaultManager.attributesOfFileSystemForPathError(
|
|
327
|
+
knownFolders.documents().path,
|
|
328
|
+
undefined,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return numericValue(attributes?.objectForKey(NSFileSystemFreeSize)) ?? Number.MAX_SAFE_INTEGER
|
|
332
|
+
} catch {
|
|
333
|
+
return Number.MAX_SAFE_INTEGER
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** iOS adapter: one AVCaptureSession shared between the attached preview
|
|
338
|
+
* and a single AVCaptureMovieFileOutput capture attempt. */
|
|
339
|
+
export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
|
|
340
|
+
let currentConfig = { ...config }
|
|
341
|
+
let session: AVCaptureSession | undefined
|
|
342
|
+
let videoInput: AVCaptureDeviceInput | undefined
|
|
343
|
+
let audioInput: AVCaptureDeviceInput | undefined
|
|
344
|
+
let movieOutput: AVCaptureMovieFileOutput | undefined
|
|
345
|
+
let previewLayer: AVCaptureVideoPreviewLayer | undefined
|
|
346
|
+
let previewHost: any
|
|
347
|
+
let detachFrame: (() => void) | undefined
|
|
348
|
+
let previewReadyFlag = false
|
|
349
|
+
let device: AVCaptureDevice | undefined
|
|
350
|
+
let observer: BackendObserver | undefined
|
|
351
|
+
let observers: NSObjectProtocol[] = []
|
|
352
|
+
let captureSink: BackendCaptureSink | undefined
|
|
353
|
+
let captureRequest: BackendCaptureRequest | undefined
|
|
354
|
+
let limitRequested = false
|
|
355
|
+
let noDurationLimit: any
|
|
356
|
+
let interrupted = false
|
|
357
|
+
|
|
358
|
+
const RecordingDelegate = (NSObject as any).extend(
|
|
359
|
+
{
|
|
360
|
+
captureOutputDidStartRecordingToOutputFileAtURLFromConnections(
|
|
361
|
+
_output: unknown,
|
|
362
|
+
_url: unknown,
|
|
363
|
+
_connections: unknown,
|
|
364
|
+
) {
|
|
365
|
+
onCaptureStarted()
|
|
366
|
+
},
|
|
367
|
+
captureOutputDidFinishRecordingToOutputFileAtURLFromConnectionsError(
|
|
368
|
+
_output: unknown,
|
|
369
|
+
url: NSURL,
|
|
370
|
+
_connections: unknown,
|
|
371
|
+
error: NSError | null,
|
|
372
|
+
) {
|
|
373
|
+
onCaptureFinished(url, error)
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{ protocols: [AVCaptureFileOutputRecordingDelegate] },
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
const recordingDelegate = RecordingDelegate.new()
|
|
380
|
+
|
|
381
|
+
const emitIssue = (
|
|
382
|
+
kind: CaptureIssue['kind'],
|
|
383
|
+
operation: string,
|
|
384
|
+
message: string,
|
|
385
|
+
cause?: unknown,
|
|
386
|
+
): CaptureIssue => ({
|
|
387
|
+
kind,
|
|
388
|
+
operation,
|
|
389
|
+
message,
|
|
390
|
+
cause,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const notify = (phase: 'began' | 'ended', reason?: CameraInterruptionReason) => {
|
|
394
|
+
interrupted = phase === 'began'
|
|
395
|
+
observer?.interruption(phase, reason)
|
|
396
|
+
observer?.previewReady(previewReadyFlag && !interrupted)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const observeSession = (target: AVCaptureSession) => {
|
|
400
|
+
const center = NSNotificationCenter.defaultCenter
|
|
401
|
+
const add = (name: string, object: unknown, handler: (note: NSNotification) => void) => {
|
|
402
|
+
observers.push(
|
|
403
|
+
center.addObserverForNameObjectQueueUsingBlock(
|
|
404
|
+
name,
|
|
405
|
+
object as any,
|
|
406
|
+
NSOperationQueue.mainQueue,
|
|
407
|
+
handler,
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
add(AVCaptureSessionWasInterruptedNotification, target, (note) => {
|
|
413
|
+
const raw = note.userInfo?.objectForKey(AVCaptureSessionInterruptionReasonKey)
|
|
414
|
+
|
|
415
|
+
notify('began', mapInterruptionReason(numericValue(raw)))
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
add(AVCaptureSessionInterruptionEndedNotification, target, () => {
|
|
419
|
+
notify('ended')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
add(AVCaptureSessionRuntimeErrorNotification, target, () => {
|
|
423
|
+
observer?.availability(false)
|
|
424
|
+
})
|
|
425
|
+
|
|
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
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
add(AVCaptureDeviceWasConnectedNotification, null, (note) => {
|
|
438
|
+
if (device && note.object === device) {
|
|
439
|
+
observer?.availability(true)
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
add(UIApplicationDidEnterBackgroundNotification, null, () => {
|
|
444
|
+
notify('began', 'background')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
add(UIApplicationWillEnterForegroundNotification, null, () => {
|
|
448
|
+
notify('ended', 'background')
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const clearObservers = () => {
|
|
453
|
+
const center = NSNotificationCenter.defaultCenter
|
|
454
|
+
for (const entry of observers) {
|
|
455
|
+
center.removeObserver(entry)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
observers = []
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const supportedPreset = (target: AVCaptureSession, captureDevice: AVCaptureDevice) =>
|
|
462
|
+
target.canSetSessionPreset(AVCaptureSessionPreset1280x720) &&
|
|
463
|
+
captureDevice.supportsAVCaptureSessionPreset(AVCaptureSessionPreset1280x720)
|
|
464
|
+
|
|
465
|
+
/** Resolve the effective standard profile for the current camera:
|
|
466
|
+
* 720p/30 when the device supports it, otherwise the device's own
|
|
467
|
+
* active format disclosed honestly. */
|
|
468
|
+
const standardProfile = (captureDevice: AVCaptureDevice | undefined) => {
|
|
469
|
+
if (captureDevice && session && supportedPreset(session, captureDevice)) {
|
|
470
|
+
return { width: 1280, height: 720, frameRate: 30 }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const format = captureDevice?.activeFormat
|
|
474
|
+
const dimensions = format
|
|
475
|
+
? CMVideoFormatDescriptionGetDimensions(format.formatDescription)
|
|
476
|
+
: undefined
|
|
477
|
+
|
|
478
|
+
const range = captureDevice?.activeVideoMinFrameDuration
|
|
479
|
+
const frameRate =
|
|
480
|
+
range && range.timescale > 0 && range.value > 0
|
|
481
|
+
? Math.round(range.timescale / range.value)
|
|
482
|
+
: 30
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
width: dimensions?.width ?? 1280,
|
|
486
|
+
height: dimensions?.height ?? 720,
|
|
487
|
+
frameRate,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const ensureAudioInput = (target: AVCaptureSession): boolean => {
|
|
492
|
+
if (audioInput) {
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const mic = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeAudio)
|
|
497
|
+
if (!mic) {
|
|
498
|
+
return false
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const input = AVCaptureDeviceInput.deviceInputWithDeviceError(mic, undefined)
|
|
502
|
+
if (!input || !target.canAddInput(input)) {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
target.addInput(input)
|
|
507
|
+
audioInput = input
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const ensureSession = (): AVCaptureSession => {
|
|
512
|
+
if (session) {
|
|
513
|
+
return session
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const target = AVCaptureSession.new()
|
|
517
|
+
target.beginConfiguration()
|
|
518
|
+
device = selectDevice(currentConfig.camera, listCameras())
|
|
519
|
+
if (!device) {
|
|
520
|
+
target.commitConfiguration()
|
|
521
|
+
throw new CameraCaptureError({
|
|
522
|
+
kind: 'unavailable',
|
|
523
|
+
operation: 'startPreview',
|
|
524
|
+
message: 'No camera device is available',
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (supportedPreset(target, device)) {
|
|
529
|
+
target.sessionPreset = AVCaptureSessionPreset1280x720
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const input = AVCaptureDeviceInput.deviceInputWithDeviceError(device, undefined)
|
|
533
|
+
if (!input || !target.canAddInput(input)) {
|
|
534
|
+
target.commitConfiguration()
|
|
535
|
+
throw new CameraCaptureError({
|
|
536
|
+
kind: 'unavailable',
|
|
537
|
+
operation: 'startPreview',
|
|
538
|
+
message: 'Camera input is unavailable',
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
target.addInput(input)
|
|
543
|
+
videoInput = input
|
|
544
|
+
const output = AVCaptureMovieFileOutput.new()
|
|
545
|
+
if (target.canAddOutput(output)) {
|
|
546
|
+
target.addOutput(output)
|
|
547
|
+
movieOutput = output
|
|
548
|
+
noDurationLimit = output.maxRecordedDuration
|
|
549
|
+
} else {
|
|
550
|
+
target.commitConfiguration()
|
|
551
|
+
throw new CameraCaptureError({
|
|
552
|
+
kind: 'unsupportedPlatform',
|
|
553
|
+
operation: 'startPreview',
|
|
554
|
+
message: 'Movie file output is unavailable on this device',
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (currentConfig.audio && readPermission('microphone') === 'granted') {
|
|
559
|
+
ensureAudioInput(target)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
target.commitConfiguration()
|
|
563
|
+
session = target
|
|
564
|
+
observeSession(target)
|
|
565
|
+
return target
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const stopSessionIfIdle = () => {
|
|
569
|
+
if (!session || captureSink || previewHost) {
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
session.stopRunning()
|
|
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
|
+
|
|
587
|
+
session = undefined
|
|
588
|
+
videoInput = undefined
|
|
589
|
+
audioInput = undefined
|
|
590
|
+
movieOutput = undefined
|
|
591
|
+
previewLayer = undefined
|
|
592
|
+
device = undefined
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const onCaptureStarted = () => {
|
|
596
|
+
const request = captureRequest
|
|
597
|
+
const sink = captureSink
|
|
598
|
+
if (!request || !sink || !device) {
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const profile = standardProfile(device)
|
|
603
|
+
const configuration: EffectiveCaptureConfig = {
|
|
604
|
+
cameraId: device.uniqueID,
|
|
605
|
+
facing: facingForPosition(device.position),
|
|
606
|
+
audio: request.audio && audioInput !== undefined,
|
|
607
|
+
width: profile.width,
|
|
608
|
+
height: profile.height,
|
|
609
|
+
frameRate: profile.frameRate,
|
|
610
|
+
orientation: request.orientation ?? 'unspecified',
|
|
611
|
+
mimeType: 'video/quicktime',
|
|
612
|
+
container: 'mov',
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
sink.started(configuration)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const onCaptureFinished = (url: NSURL, error: NSError | null) => {
|
|
619
|
+
const request = captureRequest
|
|
620
|
+
const sink = captureSink
|
|
621
|
+
captureRequest = undefined
|
|
622
|
+
captureSink = undefined
|
|
623
|
+
if (!request || !sink) {
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const filePath = url?.path ? String(url.path) : ''
|
|
628
|
+
const succeeded =
|
|
629
|
+
!error ||
|
|
630
|
+
numericValue(error.userInfo?.objectForKey(AVErrorRecordingSuccessfullyFinishedKey)) === 1
|
|
631
|
+
|
|
632
|
+
if (!succeeded) {
|
|
633
|
+
// Clean up only the file this attempt created — never a
|
|
634
|
+
// pre-existing app file (admission rejects existing paths).
|
|
635
|
+
try {
|
|
636
|
+
if (filePath && File.exists(filePath)) {
|
|
637
|
+
NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// Temporary leftovers are reclaimed by the OS.
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
sink.settled({
|
|
644
|
+
kind: 'failed',
|
|
645
|
+
stage: 'capture',
|
|
646
|
+
issue: emitIssue(
|
|
647
|
+
'captureFailed',
|
|
648
|
+
'finalize',
|
|
649
|
+
error?.localizedDescription ?? 'Movie recording did not complete',
|
|
650
|
+
error,
|
|
651
|
+
),
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
stopSessionIfIdle()
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const limitReached = limitRequested && error?.code === avErrorMaximumDurationReached
|
|
659
|
+
try {
|
|
660
|
+
const asset = AVURLAsset.assetWithURL(url)
|
|
661
|
+
const durationSeconds = asset ? CMTimeGetSeconds(asset.duration) : 0
|
|
662
|
+
const videoTracks = asset?.tracksWithMediaType(AVMediaTypeVideo)
|
|
663
|
+
const audioTracks = asset?.tracksWithMediaType(AVMediaTypeAudio)
|
|
664
|
+
const track = videoTracks && videoTracks.count > 0 ? videoTracks.objectAtIndex(0) : null
|
|
665
|
+
if (!track || !(durationSeconds > 0)) {
|
|
666
|
+
try {
|
|
667
|
+
if (filePath && File.exists(filePath)) {
|
|
668
|
+
NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
// Temporary leftovers are reclaimed by the OS.
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
sink.settled({
|
|
675
|
+
kind: 'failed',
|
|
676
|
+
stage: 'capture',
|
|
677
|
+
issue: emitIssue('noMedia', 'finalize', 'The finalized movie contains no playable media'),
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
stopSessionIfIdle()
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const naturalSize = track.naturalSize
|
|
685
|
+
const transform = track.preferredTransform
|
|
686
|
+
const orientation =
|
|
687
|
+
request.orientation && request.orientation !== 'unspecified'
|
|
688
|
+
? request.orientation
|
|
689
|
+
: orientationFromTransform(transform)
|
|
690
|
+
|
|
691
|
+
const rotated = Math.abs(transform.b) === 1 && Math.abs(transform.c) === 1
|
|
692
|
+
const width = rotated ? naturalSize.height : naturalSize.width
|
|
693
|
+
const height = rotated ? naturalSize.width : naturalSize.height
|
|
694
|
+
if (request.audio && !(audioTracks && audioTracks.count > 0)) {
|
|
695
|
+
// Requested audio missing must not masquerade as silent
|
|
696
|
+
// capture — classify as failure, not a successful clip.
|
|
697
|
+
try {
|
|
698
|
+
if (filePath && File.exists(filePath)) {
|
|
699
|
+
NSFileManager.defaultManager.removeItemAtPathError(filePath, undefined)
|
|
700
|
+
}
|
|
701
|
+
} catch {
|
|
702
|
+
// Temporary leftovers are reclaimed by the OS.
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
sink.settled({
|
|
706
|
+
kind: 'failed',
|
|
707
|
+
stage: 'capture',
|
|
708
|
+
issue: emitIssue(
|
|
709
|
+
'captureFailed',
|
|
710
|
+
'finalize',
|
|
711
|
+
'Requested audio track is missing from the finalized movie',
|
|
712
|
+
),
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
stopSessionIfIdle()
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
sink.settled({
|
|
720
|
+
kind: 'clip',
|
|
721
|
+
output: {
|
|
722
|
+
kind: 'nativeFile',
|
|
723
|
+
resourceId: `file:${filePath}`,
|
|
724
|
+
fileUrl: `file://${filePath}`,
|
|
725
|
+
},
|
|
726
|
+
mimeType: 'video/quicktime',
|
|
727
|
+
durationMs: Math.round(durationSeconds * 1000),
|
|
728
|
+
width,
|
|
729
|
+
height,
|
|
730
|
+
orientation,
|
|
731
|
+
hasAudio: request.audio && (audioTracks?.count ?? 0) > 0,
|
|
732
|
+
limitReached,
|
|
733
|
+
})
|
|
734
|
+
} catch (cause) {
|
|
735
|
+
sink.settled({
|
|
736
|
+
kind: 'failed',
|
|
737
|
+
stage: 'finalization',
|
|
738
|
+
issue: emitIssue(
|
|
739
|
+
'finalizationFailed',
|
|
740
|
+
'finalize',
|
|
741
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
742
|
+
cause,
|
|
743
|
+
),
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
stopSessionIfIdle()
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
platform: 'ios',
|
|
752
|
+
supported: typeof AVCaptureMovieFileOutput !== 'undefined',
|
|
753
|
+
checkPermission: async (kind) => {
|
|
754
|
+
if (!hasUsageDeclaration(kind)) {
|
|
755
|
+
// Missing declarations are a setup error, not a permission
|
|
756
|
+
// status — surface through start/configure failures instead of
|
|
757
|
+
// pretending a status.
|
|
758
|
+
return 'unknown'
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return readPermission(kind)
|
|
762
|
+
},
|
|
763
|
+
requestPermission: (kind) => requestAccess(kind),
|
|
764
|
+
getCapabilities: async () => {
|
|
765
|
+
const devices = listCameras()
|
|
766
|
+
const cameras: CameraDeviceInfo[] = devices.map((entry) => ({
|
|
767
|
+
id: entry.uniqueID,
|
|
768
|
+
label: entry.localizedName ?? entry.uniqueID,
|
|
769
|
+
facing: facingForPosition(entry.position),
|
|
770
|
+
}))
|
|
771
|
+
|
|
772
|
+
const selected = device ?? selectDevice(currentConfig.camera, devices)
|
|
773
|
+
const profile = selected
|
|
774
|
+
? (() => {
|
|
775
|
+
const probe = AVCaptureSession.new()
|
|
776
|
+
probe.beginConfiguration()
|
|
777
|
+
const supported = supportedPreset(probe, selected)
|
|
778
|
+
probe.commitConfiguration()
|
|
779
|
+
return supported
|
|
780
|
+
? { width: 1280, height: 720, frameRate: 30 }
|
|
781
|
+
: standardProfile(selected)
|
|
782
|
+
})()
|
|
783
|
+
: { width: 1280, height: 720, frameRate: 30 }
|
|
784
|
+
|
|
785
|
+
const available = cameras.length > 0
|
|
786
|
+
const supported = typeof AVCaptureMovieFileOutput !== 'undefined' && available
|
|
787
|
+
return {
|
|
788
|
+
supported,
|
|
789
|
+
reason: !available
|
|
790
|
+
? 'No camera device is available on this host'
|
|
791
|
+
: typeof AVCaptureMovieFileOutput === 'undefined'
|
|
792
|
+
? 'Movie file output is unsupported by this runtime'
|
|
793
|
+
: undefined,
|
|
794
|
+
available,
|
|
795
|
+
cameras,
|
|
796
|
+
profiles: [{ profile: 'standard', ...profile }],
|
|
797
|
+
audio: typeof AVCaptureDevice.defaultDeviceWithMediaType === 'function',
|
|
798
|
+
focusPointOfInterest: selected?.focusPointOfInterestSupported === true,
|
|
799
|
+
exposurePointOfInterest: selected?.exposurePointOfInterestSupported === true,
|
|
800
|
+
orientations: ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight'],
|
|
801
|
+
durationLimit: 'native',
|
|
802
|
+
elapsedTime: 'nativeMedia',
|
|
803
|
+
output: {
|
|
804
|
+
mimeType: 'video/quicktime',
|
|
805
|
+
container: 'mov',
|
|
806
|
+
storage: 'appPrivateFile',
|
|
807
|
+
destinationFileUrl: true,
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
previewReady: () => previewReadyFlag && !interrupted,
|
|
812
|
+
attachPreview: (host, listener?: BackendPreviewListener) => {
|
|
813
|
+
let target: AVCaptureSession
|
|
814
|
+
try {
|
|
815
|
+
target = ensureSession()
|
|
816
|
+
} catch (error) {
|
|
817
|
+
throw error instanceof CameraCaptureError
|
|
818
|
+
? error
|
|
819
|
+
: new CameraCaptureError({
|
|
820
|
+
kind: 'unavailable',
|
|
821
|
+
operation: 'startPreview',
|
|
822
|
+
message: error instanceof Error ? error.message : String(error),
|
|
823
|
+
cause: error,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const view = host as any
|
|
828
|
+
const nativeView = view?.ios ?? view?.nativeViewProtected
|
|
829
|
+
if (!nativeView?.layer) {
|
|
830
|
+
throw new CameraCaptureError({
|
|
831
|
+
kind: 'invalidArgument',
|
|
832
|
+
operation: 'attachPreview',
|
|
833
|
+
message: 'Camera preview host is unavailable',
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const layer = AVCaptureVideoPreviewLayer.layerWithSession(target)
|
|
838
|
+
layer.videoGravity = AVLayerVideoGravityResizeAspectFill
|
|
839
|
+
const connection = layer.connection
|
|
840
|
+
if (connection) {
|
|
841
|
+
connection.automaticallyAdjustsVideoMirroring = false
|
|
842
|
+
if (connection.supportsVideoMirroring) {
|
|
843
|
+
connection.videoMirrored = device?.position === captureDevicePositionFront
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
preferAutoStabilization(connection)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
nativeView.layer.addSublayer(layer)
|
|
850
|
+
previewLayer = layer
|
|
851
|
+
previewHost = view
|
|
852
|
+
const updateFrame = () => {
|
|
853
|
+
layer.frame = nativeView.bounds
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
detachFrame = () => view.off?.('layoutChanged', updateFrame)
|
|
857
|
+
view.on?.('layoutChanged', updateFrame)
|
|
858
|
+
updateFrame()
|
|
859
|
+
target.startRunning()
|
|
860
|
+
previewReadyFlag = true
|
|
861
|
+
observer?.previewReady(true)
|
|
862
|
+
listener?.onReady?.()
|
|
863
|
+
return () => {
|
|
864
|
+
previewReadyFlag = false
|
|
865
|
+
view.off?.('layoutChanged', updateFrame)
|
|
866
|
+
layer.removeFromSuperlayer()
|
|
867
|
+
previewLayer = undefined
|
|
868
|
+
previewHost = undefined
|
|
869
|
+
detachFrame = undefined
|
|
870
|
+
observer?.previewReady(false)
|
|
871
|
+
stopSessionIfIdle()
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
configure: (next) => {
|
|
875
|
+
const previous = currentConfig
|
|
876
|
+
currentConfig = { ...next }
|
|
877
|
+
const target = session
|
|
878
|
+
if (!target || !device) {
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
target.beginConfiguration()
|
|
883
|
+
const wanted = selectDevice(next.camera ?? previous.camera, listCameras())
|
|
884
|
+
if (wanted && wanted !== device && videoInput) {
|
|
885
|
+
const input = AVCaptureDeviceInput.deviceInputWithDeviceError(wanted, undefined)
|
|
886
|
+
if (input && target.canAddInput(input)) {
|
|
887
|
+
target.removeInput(videoInput)
|
|
888
|
+
target.addInput(input)
|
|
889
|
+
videoInput = input
|
|
890
|
+
device = wanted
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (next.audio === true && readPermission('microphone') === 'granted') {
|
|
895
|
+
ensureAudioInput(target)
|
|
896
|
+
} else if (next.audio === false && audioInput) {
|
|
897
|
+
target.removeInput(audioInput)
|
|
898
|
+
audioInput = undefined
|
|
899
|
+
}
|
|
900
|
+
|
|
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
|
+
}
|
|
962
|
+
},
|
|
963
|
+
startCapture: (request, sink) => {
|
|
964
|
+
const target = session
|
|
965
|
+
if (!target || !movieOutput || !device) {
|
|
966
|
+
sink.settled({
|
|
967
|
+
kind: 'failed',
|
|
968
|
+
stage: 'preparation',
|
|
969
|
+
issue: emitIssue('unavailable', 'startRecording', 'The camera session is not running'),
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (!hasUsageDeclaration('camera')) {
|
|
976
|
+
sink.settled({
|
|
977
|
+
kind: 'failed',
|
|
978
|
+
stage: 'preparation',
|
|
979
|
+
issue: emitIssue(
|
|
980
|
+
'configurationMissing',
|
|
981
|
+
'startRecording',
|
|
982
|
+
"Missing NSCameraUsageDescription in the host app's Info.plist",
|
|
983
|
+
),
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (request.audio) {
|
|
990
|
+
if (!hasUsageDeclaration('microphone')) {
|
|
991
|
+
sink.settled({
|
|
992
|
+
kind: 'failed',
|
|
993
|
+
stage: 'preparation',
|
|
994
|
+
issue: emitIssue(
|
|
995
|
+
'configurationMissing',
|
|
996
|
+
'startRecording',
|
|
997
|
+
"Missing NSMicrophoneUsageDescription in the host app's Info.plist",
|
|
998
|
+
),
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const micStatus = readPermission('microphone')
|
|
1005
|
+
if (micStatus !== 'granted') {
|
|
1006
|
+
sink.settled({
|
|
1007
|
+
kind: 'failed',
|
|
1008
|
+
stage: 'preparation',
|
|
1009
|
+
issue: emitIssue(
|
|
1010
|
+
micStatus === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
|
|
1011
|
+
'startRecording',
|
|
1012
|
+
`Microphone permission is ${micStatus}`,
|
|
1013
|
+
{ permission: 'microphone' },
|
|
1014
|
+
),
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (!audioInput) {
|
|
1021
|
+
target.beginConfiguration()
|
|
1022
|
+
const added = ensureAudioInput(target)
|
|
1023
|
+
target.commitConfiguration()
|
|
1024
|
+
if (!added) {
|
|
1025
|
+
sink.settled({
|
|
1026
|
+
kind: 'failed',
|
|
1027
|
+
stage: 'preparation',
|
|
1028
|
+
issue: emitIssue(
|
|
1029
|
+
'unavailable',
|
|
1030
|
+
'startRecording',
|
|
1031
|
+
'No audio input device is available',
|
|
1032
|
+
),
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const connection = movieOutput.connectionWithMediaType(AVMediaTypeVideo)
|
|
1041
|
+
if (request.orientation) {
|
|
1042
|
+
const angle = orientationToRotationAngle(request.orientation)
|
|
1043
|
+
const legacy = orientationToLegacyConstant(request.orientation)
|
|
1044
|
+
let applied = false
|
|
1045
|
+
if (
|
|
1046
|
+
connection &&
|
|
1047
|
+
angle !== undefined &&
|
|
1048
|
+
typeof connection.isVideoRotationAngleSupported === 'function' &&
|
|
1049
|
+
connection.isVideoRotationAngleSupported(angle)
|
|
1050
|
+
) {
|
|
1051
|
+
connection.videoRotationAngle = angle
|
|
1052
|
+
applied = true
|
|
1053
|
+
} else if (connection && legacy !== undefined && connection.supportsVideoOrientation) {
|
|
1054
|
+
connection.videoOrientation = legacy
|
|
1055
|
+
applied = true
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!applied) {
|
|
1059
|
+
sink.settled({
|
|
1060
|
+
kind: 'failed',
|
|
1061
|
+
stage: 'preparation',
|
|
1062
|
+
issue: emitIssue(
|
|
1063
|
+
'unsupportedConfiguration',
|
|
1064
|
+
'startRecording',
|
|
1065
|
+
`Orientation '${request.orientation}' is not supported by this connection`,
|
|
1066
|
+
),
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (connection) {
|
|
1074
|
+
// Recorded output is unmirrored, including the front camera —
|
|
1075
|
+
// the preview may stay mirrored under its existing behavior.
|
|
1076
|
+
connection.automaticallyAdjustsVideoMirroring = false
|
|
1077
|
+
if (connection.supportsVideoMirroring) {
|
|
1078
|
+
connection.videoMirrored = false
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
preferAutoStabilization(connection)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
let filePath: string
|
|
1085
|
+
if (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)) {
|
|
1094
|
+
sink.settled({
|
|
1095
|
+
kind: 'failed',
|
|
1096
|
+
stage: 'preparation',
|
|
1097
|
+
issue: emitIssue(
|
|
1098
|
+
'destinationUnavailable',
|
|
1099
|
+
'startRecording',
|
|
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',
|
|
1105
|
+
),
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
filePath = destination
|
|
1112
|
+
} else {
|
|
1113
|
+
try {
|
|
1114
|
+
filePath = path.join(
|
|
1115
|
+
recordingsDirectory(),
|
|
1116
|
+
`clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mov`,
|
|
1117
|
+
)
|
|
1118
|
+
} catch (cause) {
|
|
1119
|
+
sink.settled({
|
|
1120
|
+
kind: 'failed',
|
|
1121
|
+
stage: 'preparation',
|
|
1122
|
+
issue: emitIssue(
|
|
1123
|
+
'destinationUnavailable',
|
|
1124
|
+
'startRecording',
|
|
1125
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
1126
|
+
cause,
|
|
1127
|
+
),
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (freeDiskBytes() < minFreeDiskBytes) {
|
|
1135
|
+
sink.settled({
|
|
1136
|
+
kind: 'failed',
|
|
1137
|
+
stage: 'preparation',
|
|
1138
|
+
issue: emitIssue(
|
|
1139
|
+
'insufficientStorage',
|
|
1140
|
+
'startRecording',
|
|
1141
|
+
'Not enough local storage for a movie recording',
|
|
1142
|
+
),
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
limitRequested = request.maximumDurationMs !== undefined
|
|
1149
|
+
movieOutput.maxRecordedDuration =
|
|
1150
|
+
request.maximumDurationMs !== undefined
|
|
1151
|
+
? CMTimeMakeWithSeconds(request.maximumDurationMs / 1000, 600)
|
|
1152
|
+
: (noDurationLimit ?? movieOutput.maxRecordedDuration)
|
|
1153
|
+
|
|
1154
|
+
captureRequest = request
|
|
1155
|
+
captureSink = sink
|
|
1156
|
+
try {
|
|
1157
|
+
movieOutput.startRecordingToOutputFileURLRecordingDelegate(
|
|
1158
|
+
NSURL.fileURLWithPath(filePath),
|
|
1159
|
+
recordingDelegate,
|
|
1160
|
+
)
|
|
1161
|
+
} catch (cause) {
|
|
1162
|
+
captureRequest = undefined
|
|
1163
|
+
captureSink = undefined
|
|
1164
|
+
sink.settled({
|
|
1165
|
+
kind: 'failed',
|
|
1166
|
+
stage: 'preparation',
|
|
1167
|
+
issue: emitIssue(
|
|
1168
|
+
'captureFailed',
|
|
1169
|
+
'startRecording',
|
|
1170
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
1171
|
+
cause,
|
|
1172
|
+
),
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
stopCapture: () => {
|
|
1177
|
+
try {
|
|
1178
|
+
if (movieOutput?.recording) {
|
|
1179
|
+
movieOutput.stopRecording()
|
|
1180
|
+
}
|
|
1181
|
+
} catch {
|
|
1182
|
+
// Settlement still arrives through the delegate callback.
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
elapsedMs: () => {
|
|
1186
|
+
try {
|
|
1187
|
+
const duration = movieOutput?.recordedDuration
|
|
1188
|
+
if (!duration) {
|
|
1189
|
+
return 0
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return Math.max(0, Math.round(CMTimeGetSeconds(duration) * 1000))
|
|
1193
|
+
} catch {
|
|
1194
|
+
return 0
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
openOutput: async (output) => {
|
|
1198
|
+
if (output.kind !== 'nativeFile') {
|
|
1199
|
+
throw new CameraCaptureError({
|
|
1200
|
+
kind: 'invalidArgument',
|
|
1201
|
+
operation: 'openOutput',
|
|
1202
|
+
message: 'Only file-backed output can be opened on iOS',
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const filePath = output.fileUrl.slice('file://'.length)
|
|
1207
|
+
if (!File.exists(filePath)) {
|
|
1208
|
+
throw new CameraCaptureError({
|
|
1209
|
+
kind: 'unavailable',
|
|
1210
|
+
operation: 'openOutput',
|
|
1211
|
+
message: `Movie file is missing at ${output.fileUrl}`,
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const handle: MovieOutputHandle = {
|
|
1216
|
+
url: output.fileUrl,
|
|
1217
|
+
fileUrl: output.fileUrl,
|
|
1218
|
+
release: () => {},
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return handle
|
|
1222
|
+
},
|
|
1223
|
+
release: async () => {
|
|
1224
|
+
previewReadyFlag = false
|
|
1225
|
+
detachFrame?.()
|
|
1226
|
+
detachFrame = undefined
|
|
1227
|
+
if (previewHost) {
|
|
1228
|
+
try {
|
|
1229
|
+
previewLayer?.removeFromSuperlayer()
|
|
1230
|
+
} catch {}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
previewHost = undefined
|
|
1234
|
+
previewLayer = undefined
|
|
1235
|
+
captureRequest = undefined
|
|
1236
|
+
captureSink = undefined
|
|
1237
|
+
clearObservers()
|
|
1238
|
+
interrupted = false
|
|
1239
|
+
try {
|
|
1240
|
+
session?.stopRunning()
|
|
1241
|
+
} catch {}
|
|
1242
|
+
|
|
1243
|
+
session = undefined
|
|
1244
|
+
videoInput = undefined
|
|
1245
|
+
audioInput = undefined
|
|
1246
|
+
movieOutput = undefined
|
|
1247
|
+
device = undefined
|
|
1248
|
+
},
|
|
1249
|
+
onPreviewDetached: () => {
|
|
1250
|
+
// The view went away; if a settle is still in flight the session
|
|
1251
|
+
// stays alive until it arrives (stopSessionIfIdle re-checks).
|
|
1252
|
+
stopSessionIfIdle()
|
|
1253
|
+
},
|
|
1254
|
+
setObserver: (next) => {
|
|
1255
|
+
observer = next
|
|
1256
|
+
},
|
|
1257
|
+
}
|
|
1258
|
+
}
|