@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,707 @@
|
|
|
1
|
+
import '@nativescript/macos-node-api'
|
|
2
|
+
|
|
3
|
+
import { CameraCaptureError } from './types'
|
|
4
|
+
import { canonicalizePath } from './session-core'
|
|
5
|
+
import type { BackendCaptureRequest, BackendCaptureSink, BackendObserver } from './session-core'
|
|
6
|
+
import type { CameraSessionBackend } from './session-core'
|
|
7
|
+
import type { BackendPreviewListener } from './session-core'
|
|
8
|
+
import type {
|
|
9
|
+
CameraDeviceInfo,
|
|
10
|
+
CameraOrientation,
|
|
11
|
+
CameraPermissionKind,
|
|
12
|
+
CameraPermissionStatus,
|
|
13
|
+
CameraSessionCapabilities,
|
|
14
|
+
CameraSessionConfig,
|
|
15
|
+
CaptureIssue,
|
|
16
|
+
EffectiveCaptureConfig,
|
|
17
|
+
MovieOutputHandle,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
declare const NSFileManager: any
|
|
21
|
+
|
|
22
|
+
interface CameraHost {
|
|
23
|
+
attachPreview(view: object): string | null
|
|
24
|
+
detachPreview(): void
|
|
25
|
+
installObserver(emit: (message: string) => void): void
|
|
26
|
+
permissionStatus(kind: string): string
|
|
27
|
+
requestPermissionReply(kind: string, reply: (status: string) => void): void
|
|
28
|
+
devices(): string
|
|
29
|
+
audioDeviceAvailable(): boolean
|
|
30
|
+
audioInputAttached(): boolean
|
|
31
|
+
activeDeviceId(): string | null
|
|
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
|
|
36
|
+
supportsRotationAngles(): boolean
|
|
37
|
+
movieDirectory(): string | null
|
|
38
|
+
startRecording(path: string, maximumDurationMs: number): string | null
|
|
39
|
+
stopRecording(): void
|
|
40
|
+
recording(): boolean
|
|
41
|
+
recordedMs(): number
|
|
42
|
+
previewSize(): string
|
|
43
|
+
dispose(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
declare const XplatCameraHost: {
|
|
47
|
+
alloc(): { init(): CameraHost }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type HostEvent =
|
|
51
|
+
| { type: 'recordingStarted'; fileUrl: string; audio: boolean }
|
|
52
|
+
| {
|
|
53
|
+
type: 'recordingFinished'
|
|
54
|
+
fileUrl: string
|
|
55
|
+
succeeded: boolean
|
|
56
|
+
limitReached: boolean
|
|
57
|
+
clip?: {
|
|
58
|
+
fileUrl: string
|
|
59
|
+
durationMs: number
|
|
60
|
+
width: number
|
|
61
|
+
height: number
|
|
62
|
+
rotationAngle: number
|
|
63
|
+
fileSize: number
|
|
64
|
+
hasAudio: boolean
|
|
65
|
+
videoCodec?: string
|
|
66
|
+
}
|
|
67
|
+
error?: { domain: string; code: number; message: string }
|
|
68
|
+
}
|
|
69
|
+
| { type: 'interrupted'; cause: string }
|
|
70
|
+
| { type: 'interruptionEnded' }
|
|
71
|
+
| { type: 'availability'; available: boolean }
|
|
72
|
+
| { type: 'previewReady'; ready: boolean }
|
|
73
|
+
|
|
74
|
+
const orientationToRotationAngle = (orientation: CameraOrientation): number | undefined =>
|
|
75
|
+
orientation === 'portrait'
|
|
76
|
+
? 90
|
|
77
|
+
: orientation === 'portraitUpsideDown'
|
|
78
|
+
? 270
|
|
79
|
+
: orientation === 'landscapeLeft'
|
|
80
|
+
? 180
|
|
81
|
+
: orientation === 'landscapeRight'
|
|
82
|
+
? 0
|
|
83
|
+
: undefined
|
|
84
|
+
|
|
85
|
+
const orientationFromRotationAngle = (angle: number): CameraOrientation =>
|
|
86
|
+
angle === 90
|
|
87
|
+
? 'portrait'
|
|
88
|
+
: angle === 270
|
|
89
|
+
? 'portraitUpsideDown'
|
|
90
|
+
: angle === 180
|
|
91
|
+
? 'landscapeLeft'
|
|
92
|
+
: angle === 0
|
|
93
|
+
? 'landscapeRight'
|
|
94
|
+
: 'unspecified'
|
|
95
|
+
|
|
96
|
+
const usageKey = (kind: CameraPermissionKind) =>
|
|
97
|
+
kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'
|
|
98
|
+
|
|
99
|
+
/** AppKit adapter: one XplatCameraHost (AVCaptureSession) shared between the
|
|
100
|
+
* attached preview and a single AVCaptureMovieFileOutput capture attempt.
|
|
101
|
+
* The Swift leaf owns the session, inputs, preview layer, and recording
|
|
102
|
+
* delegate; this adapter owns admission, semantics, and contract mapping. */
|
|
103
|
+
export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
|
|
104
|
+
const host = XplatCameraHost.alloc().init()
|
|
105
|
+
let currentConfig = { ...config }
|
|
106
|
+
let previewReadyFlag = false
|
|
107
|
+
let observer: BackendObserver | undefined
|
|
108
|
+
let captureSink: BackendCaptureSink | undefined
|
|
109
|
+
let captureRequest: BackendCaptureRequest | undefined
|
|
110
|
+
let captureFilePath = ''
|
|
111
|
+
let released = false
|
|
112
|
+
|
|
113
|
+
const emitIssue = (
|
|
114
|
+
kind: CaptureIssue['kind'],
|
|
115
|
+
operation: string,
|
|
116
|
+
message: string,
|
|
117
|
+
cause?: unknown,
|
|
118
|
+
): CaptureIssue => ({ kind, operation, message, cause })
|
|
119
|
+
|
|
120
|
+
const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
|
|
121
|
+
new CameraCaptureError({ kind, operation, message, cause })
|
|
122
|
+
|
|
123
|
+
const devices = (): CameraDeviceInfo[] => {
|
|
124
|
+
try {
|
|
125
|
+
return (
|
|
126
|
+
JSON.parse(host.devices()) as {
|
|
127
|
+
id: string
|
|
128
|
+
name: string
|
|
129
|
+
facing?: 'front' | 'back' | null
|
|
130
|
+
}[]
|
|
131
|
+
).map((entry) => ({
|
|
132
|
+
id: entry.id,
|
|
133
|
+
label: entry.name,
|
|
134
|
+
facing: entry.facing ?? undefined,
|
|
135
|
+
}))
|
|
136
|
+
} catch {
|
|
137
|
+
return []
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const previewSize = (): { width: number; height: number } | undefined => {
|
|
142
|
+
try {
|
|
143
|
+
const size = JSON.parse(host.previewSize())
|
|
144
|
+
return size.width > 0 ? { width: size.width, height: size.height } : undefined
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
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
|
|
173
|
+
if (selection && typeof selection === 'object' && 'deviceId' in selection) {
|
|
174
|
+
return selection.deviceId
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 'default'/'front'/'back' resolve to the first available device —
|
|
178
|
+
// macOS cameras carry no front/back facing semantics.
|
|
179
|
+
return devices()[0]?.id
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
host.installObserver((message) => {
|
|
183
|
+
let event: HostEvent
|
|
184
|
+
try {
|
|
185
|
+
event = JSON.parse(message)
|
|
186
|
+
} catch {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
switch (event.type) {
|
|
191
|
+
case 'recordingStarted': {
|
|
192
|
+
if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const size = previewSize()
|
|
197
|
+
const configuration: EffectiveCaptureConfig = {
|
|
198
|
+
cameraId: host.activeDeviceId() ?? selectedCameraId() ?? '',
|
|
199
|
+
audio: event.audio,
|
|
200
|
+
width: size?.width ?? 1280,
|
|
201
|
+
height: size?.height ?? 720,
|
|
202
|
+
frameRate: 30,
|
|
203
|
+
orientation: captureRequest.orientation ?? 'unspecified',
|
|
204
|
+
mimeType: 'video/quicktime',
|
|
205
|
+
container: 'mov',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
captureSink.started(configuration)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
case 'recordingFinished': {
|
|
212
|
+
if (!sameFile(event.fileUrl, captureFilePath) || !captureSink || !captureRequest) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sink = captureSink
|
|
217
|
+
const request = captureRequest
|
|
218
|
+
captureSink = undefined
|
|
219
|
+
captureRequest = undefined
|
|
220
|
+
|
|
221
|
+
if (!event.succeeded || !event.clip) {
|
|
222
|
+
sink.settled({
|
|
223
|
+
kind: 'failed',
|
|
224
|
+
stage: 'capture',
|
|
225
|
+
issue: emitIssue(
|
|
226
|
+
'captureFailed',
|
|
227
|
+
'finalize',
|
|
228
|
+
event.error?.message ?? 'Movie recording did not complete',
|
|
229
|
+
),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return
|
|
233
|
+
}
|
|
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
|
+
|
|
249
|
+
if (request.audio && !event.clip.hasAudio) {
|
|
250
|
+
sink.settled({
|
|
251
|
+
kind: 'failed',
|
|
252
|
+
stage: 'capture',
|
|
253
|
+
issue: emitIssue(
|
|
254
|
+
'captureFailed',
|
|
255
|
+
'finalize',
|
|
256
|
+
'Requested audio track is missing from the finalized movie',
|
|
257
|
+
),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
sink.settled({
|
|
264
|
+
kind: 'clip',
|
|
265
|
+
output: {
|
|
266
|
+
kind: 'nativeFile',
|
|
267
|
+
resourceId: event.fileUrl,
|
|
268
|
+
fileUrl: event.fileUrl,
|
|
269
|
+
},
|
|
270
|
+
mimeType: 'video/quicktime',
|
|
271
|
+
durationMs: event.clip.durationMs,
|
|
272
|
+
width: event.clip.width,
|
|
273
|
+
height: event.clip.height,
|
|
274
|
+
orientation:
|
|
275
|
+
request.orientation ?? orientationFromRotationAngle(event.clip.rotationAngle),
|
|
276
|
+
hasAudio: request.audio && event.clip.hasAudio,
|
|
277
|
+
limitReached: event.limitReached,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
case 'interrupted':
|
|
283
|
+
observer?.interruption(
|
|
284
|
+
'began',
|
|
285
|
+
(event.cause as Parameters<BackendObserver['interruption']>[1]) ?? 'unknown',
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
observer?.previewReady(previewReadyFlag)
|
|
289
|
+
return
|
|
290
|
+
case 'interruptionEnded':
|
|
291
|
+
observer?.interruption('ended')
|
|
292
|
+
observer?.previewReady(previewReadyFlag)
|
|
293
|
+
return
|
|
294
|
+
case 'availability':
|
|
295
|
+
observer?.availability(event.available)
|
|
296
|
+
return
|
|
297
|
+
case 'previewReady':
|
|
298
|
+
previewReadyFlag = event.ready
|
|
299
|
+
observer?.previewReady(previewReadyFlag)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const checkPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
305
|
+
const status = host.permissionStatus(kind)
|
|
306
|
+
return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const requestPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
310
|
+
if (host.permissionStatus(kind) === 'undeclared') {
|
|
311
|
+
throw error(
|
|
312
|
+
'configurationMissing',
|
|
313
|
+
'requestPermission',
|
|
314
|
+
`Missing ${usageKey(kind)} in the app Info.plist`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const status = await new Promise<string>((resolve) =>
|
|
319
|
+
host.requestPermissionReply(kind, resolve),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const recordingsDirectory = (): string => {
|
|
326
|
+
const root = host.movieDirectory()
|
|
327
|
+
if (!root) {
|
|
328
|
+
throw error(
|
|
329
|
+
'destinationUnavailable',
|
|
330
|
+
'startRecording',
|
|
331
|
+
'The app has no writable private camera directory',
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return decodeURIComponent(root.replace(/^file:\/\//, '').replace(/\/$/, ''))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Durable output must stay app-private: destinations resolve inside the
|
|
339
|
+
// app's own Application Support root — the recordings directory's parent.
|
|
340
|
+
const appPrivateRoot = () =>
|
|
341
|
+
recordingsDirectory().slice(0, recordingsDirectory().lastIndexOf('/'))
|
|
342
|
+
|
|
343
|
+
const pathExists = (path: string): boolean =>
|
|
344
|
+
Boolean(NSFileManager.defaultManager.fileExistsAtPath(path))
|
|
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
|
+
|
|
362
|
+
return {
|
|
363
|
+
platform: 'macos',
|
|
364
|
+
get supported() {
|
|
365
|
+
return !released
|
|
366
|
+
},
|
|
367
|
+
checkPermission,
|
|
368
|
+
requestPermission,
|
|
369
|
+
getCapabilities: async (): Promise<CameraSessionCapabilities> => {
|
|
370
|
+
const list = devices()
|
|
371
|
+
const available = list.length > 0
|
|
372
|
+
const size = previewSize()
|
|
373
|
+
const pointOfInterest = pointOfInterestSupport()
|
|
374
|
+
return {
|
|
375
|
+
supported: available,
|
|
376
|
+
reason: available ? undefined : 'No camera device is available on this host',
|
|
377
|
+
available,
|
|
378
|
+
cameras: list,
|
|
379
|
+
profiles: [
|
|
380
|
+
{
|
|
381
|
+
profile: 'standard',
|
|
382
|
+
width: size?.width ?? 1280,
|
|
383
|
+
height: size?.height ?? 720,
|
|
384
|
+
frameRate: 30,
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
audio: host.audioDeviceAvailable(),
|
|
388
|
+
focusPointOfInterest: pointOfInterest.focus,
|
|
389
|
+
exposurePointOfInterest: pointOfInterest.exposure,
|
|
390
|
+
// AVCaptureConnection rotation angles exist on macOS 14+;
|
|
391
|
+
// earlier hosts cannot lock a take's orientation.
|
|
392
|
+
orientations: host.supportsRotationAngles()
|
|
393
|
+
? ['landscapeLeft', 'landscapeRight', 'portrait', 'portraitUpsideDown']
|
|
394
|
+
: [],
|
|
395
|
+
durationLimit: 'native',
|
|
396
|
+
elapsedTime: 'nativeMedia',
|
|
397
|
+
output: {
|
|
398
|
+
mimeType: 'video/quicktime',
|
|
399
|
+
container: 'mov',
|
|
400
|
+
storage: 'appPrivateFile',
|
|
401
|
+
destinationFileUrl: true,
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
previewReady: () => previewReadyFlag,
|
|
406
|
+
attachPreview: (target, listener?: BackendPreviewListener) => {
|
|
407
|
+
if (released) {
|
|
408
|
+
throw error('unavailable', 'attachPreview', 'CameraSession is disposed')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (host.permissionStatus('camera') !== 'granted') {
|
|
412
|
+
throw error(
|
|
413
|
+
'permissionDenied',
|
|
414
|
+
'attachPreview',
|
|
415
|
+
'Camera preview requires an explicit granted camera permission',
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!devices().length) {
|
|
420
|
+
throw error('unavailable', 'attachPreview', 'No camera device is available on this host')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const configured = host.configure({
|
|
424
|
+
cameraId: selectedCameraId(),
|
|
425
|
+
audio: currentConfig.audio ?? false,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
if (configured) {
|
|
429
|
+
throw error(
|
|
430
|
+
configured === 'busy'
|
|
431
|
+
? 'busy'
|
|
432
|
+
: configured === 'permissionDenied'
|
|
433
|
+
? 'permissionDenied'
|
|
434
|
+
: 'unavailable',
|
|
435
|
+
'attachPreview',
|
|
436
|
+
`Failed to configure the capture session: ${configured}`,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const failure = host.attachPreview(target as object)
|
|
441
|
+
if (failure) {
|
|
442
|
+
throw error(
|
|
443
|
+
failure === 'permissionDenied' ? 'permissionDenied' : 'unavailable',
|
|
444
|
+
'attachPreview',
|
|
445
|
+
`Failed to start camera preview: ${failure}`,
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
listener?.onReady?.()
|
|
450
|
+
return () => {
|
|
451
|
+
previewReadyFlag = false
|
|
452
|
+
observer?.previewReady(false)
|
|
453
|
+
host.detachPreview()
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
configure: (next) => {
|
|
457
|
+
const configured = host.configure({
|
|
458
|
+
cameraId: selectedCameraId(next),
|
|
459
|
+
audio: next.audio ?? false,
|
|
460
|
+
})
|
|
461
|
+
|
|
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.
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
startCapture: (request, sink) => {
|
|
486
|
+
if (host.permissionStatus('camera') === 'undeclared') {
|
|
487
|
+
sink.settled({
|
|
488
|
+
kind: 'failed',
|
|
489
|
+
stage: 'preparation',
|
|
490
|
+
issue: emitIssue(
|
|
491
|
+
'configurationMissing',
|
|
492
|
+
'startRecording',
|
|
493
|
+
"Missing NSCameraUsageDescription in the host app's Info.plist",
|
|
494
|
+
),
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (request.audio) {
|
|
501
|
+
if (host.permissionStatus('microphone') === 'undeclared') {
|
|
502
|
+
sink.settled({
|
|
503
|
+
kind: 'failed',
|
|
504
|
+
stage: 'preparation',
|
|
505
|
+
issue: emitIssue(
|
|
506
|
+
'configurationMissing',
|
|
507
|
+
'startRecording',
|
|
508
|
+
"Missing NSMicrophoneUsageDescription in the host app's Info.plist",
|
|
509
|
+
),
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const mic = host.permissionStatus('microphone')
|
|
516
|
+
if (mic !== 'granted') {
|
|
517
|
+
sink.settled({
|
|
518
|
+
kind: 'failed',
|
|
519
|
+
stage: 'preparation',
|
|
520
|
+
issue: emitIssue(
|
|
521
|
+
mic === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
|
|
522
|
+
'startRecording',
|
|
523
|
+
`Microphone permission is ${mic}`,
|
|
524
|
+
),
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!host.audioDeviceAvailable()) {
|
|
531
|
+
sink.settled({
|
|
532
|
+
kind: 'failed',
|
|
533
|
+
stage: 'preparation',
|
|
534
|
+
issue: emitIssue('unavailable', 'startRecording', 'No audio input device is available'),
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const rotationAngle =
|
|
542
|
+
request.orientation !== undefined
|
|
543
|
+
? orientationToRotationAngle(request.orientation)
|
|
544
|
+
: undefined
|
|
545
|
+
|
|
546
|
+
if (request.orientation !== undefined && rotationAngle === undefined) {
|
|
547
|
+
sink.settled({
|
|
548
|
+
kind: 'failed',
|
|
549
|
+
stage: 'preparation',
|
|
550
|
+
issue: emitIssue(
|
|
551
|
+
'unsupportedConfiguration',
|
|
552
|
+
'startRecording',
|
|
553
|
+
`Orientation '${request.orientation}' is not supported by this connection`,
|
|
554
|
+
),
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const configured = host.configure({
|
|
561
|
+
cameraId: selectedCameraId(),
|
|
562
|
+
audio: request.audio || (currentConfig.audio ?? false),
|
|
563
|
+
rotationAngle,
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
if (configured) {
|
|
567
|
+
sink.settled({
|
|
568
|
+
kind: 'failed',
|
|
569
|
+
stage: 'preparation',
|
|
570
|
+
issue: emitIssue(
|
|
571
|
+
configured === 'unsupportedConfiguration' ? 'unsupportedConfiguration' : 'unavailable',
|
|
572
|
+
'startRecording',
|
|
573
|
+
configured === 'unsupportedConfiguration'
|
|
574
|
+
? `Orientation '${request.orientation}' is not supported by this connection`
|
|
575
|
+
: 'The requested capture configuration is unavailable',
|
|
576
|
+
),
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let filePath: string
|
|
583
|
+
if (request.destinationPath) {
|
|
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('/'))
|
|
589
|
+
|
|
590
|
+
const directoryExists = inside && pathExists(directory)
|
|
591
|
+
const fileExists = inside && pathExists(destination)
|
|
592
|
+
if (!inside || !directoryExists || fileExists) {
|
|
593
|
+
sink.settled({
|
|
594
|
+
kind: 'failed',
|
|
595
|
+
stage: 'preparation',
|
|
596
|
+
issue: emitIssue(
|
|
597
|
+
'destinationUnavailable',
|
|
598
|
+
'startRecording',
|
|
599
|
+
!inside
|
|
600
|
+
? 'destinationFileUrl must resolve inside the app-private recordings directory'
|
|
601
|
+
: fileExists
|
|
602
|
+
? 'destinationFileUrl already exists'
|
|
603
|
+
: 'destinationFileUrl directory does not exist',
|
|
604
|
+
),
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!/\.mov$/i.test(destination)) {
|
|
611
|
+
sink.settled({
|
|
612
|
+
kind: 'failed',
|
|
613
|
+
stage: 'preparation',
|
|
614
|
+
issue: emitIssue(
|
|
615
|
+
'destinationUnavailable',
|
|
616
|
+
'startRecording',
|
|
617
|
+
'destinationFileUrl must use a .mov file name',
|
|
618
|
+
),
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
filePath = destination
|
|
625
|
+
} else {
|
|
626
|
+
try {
|
|
627
|
+
filePath = `${recordingsDirectory()}/clip-${Date.now()}-${Math.random()
|
|
628
|
+
.toString(36)
|
|
629
|
+
.slice(2, 8)}.mov`
|
|
630
|
+
} catch (cause) {
|
|
631
|
+
sink.settled({
|
|
632
|
+
kind: 'failed',
|
|
633
|
+
stage: 'preparation',
|
|
634
|
+
issue: emitIssue(
|
|
635
|
+
'destinationUnavailable',
|
|
636
|
+
'startRecording',
|
|
637
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
638
|
+
cause,
|
|
639
|
+
),
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
captureFilePath = filePath
|
|
647
|
+
|
|
648
|
+
captureSink = sink
|
|
649
|
+
captureRequest = request
|
|
650
|
+
const failure = host.startRecording(filePath, request.maximumDurationMs ?? 0)
|
|
651
|
+
if (failure) {
|
|
652
|
+
captureSink = undefined
|
|
653
|
+
captureRequest = undefined
|
|
654
|
+
captureFilePath = ''
|
|
655
|
+
sink.settled({
|
|
656
|
+
kind: 'failed',
|
|
657
|
+
stage: 'preparation',
|
|
658
|
+
issue: emitIssue(
|
|
659
|
+
'unavailable',
|
|
660
|
+
'startRecording',
|
|
661
|
+
`Failed to start movie capture: ${failure}`,
|
|
662
|
+
),
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
stopCapture: () => {
|
|
667
|
+
host.stopRecording()
|
|
668
|
+
},
|
|
669
|
+
elapsedMs: () => host.recordedMs(),
|
|
670
|
+
openOutput: async (output) => {
|
|
671
|
+
if (output.kind !== 'nativeFile' || !output.fileUrl) {
|
|
672
|
+
throw error(
|
|
673
|
+
'invalidArgument',
|
|
674
|
+
'openOutput',
|
|
675
|
+
'Only file-backed output can be opened on macOS',
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const filePath = decodeURIComponent(output.fileUrl.slice('file://'.length))
|
|
680
|
+
if (!pathExists(filePath)) {
|
|
681
|
+
throw error('unavailable', 'openOutput', `Movie file is missing at ${output.fileUrl}`)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const handle: MovieOutputHandle = {
|
|
685
|
+
url: output.fileUrl,
|
|
686
|
+
fileUrl: output.fileUrl,
|
|
687
|
+
release: () => {},
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return handle
|
|
691
|
+
},
|
|
692
|
+
release: async () => {
|
|
693
|
+
released = true
|
|
694
|
+
previewReadyFlag = false
|
|
695
|
+
captureSink = undefined
|
|
696
|
+
captureRequest = undefined
|
|
697
|
+
host.dispose()
|
|
698
|
+
},
|
|
699
|
+
onPreviewDetached: () => {
|
|
700
|
+
// The view went away while a take settles; the Swift side keeps the
|
|
701
|
+
// session alive until recordingFinished arrives.
|
|
702
|
+
},
|
|
703
|
+
setObserver: (next) => {
|
|
704
|
+
observer = next
|
|
705
|
+
},
|
|
706
|
+
}
|
|
707
|
+
}
|