@octane-xplat/camera 0.12.0 → 0.14.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 +63 -3
- package/dist/linux/CameraView.web.tsrx.js +337 -0
- package/dist/linux/host-movie.web.js +100 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/layout-child.web.js +29 -0
- package/dist/linux/movie-metadata.web.js +65 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/session-backend.linux.js +479 -0
- package/dist/linux/session-core.js +368 -0
- package/dist/linux/session.js +29 -0
- package/dist/linux/synthetic-session.js +280 -0
- package/dist/linux/types.js +19 -0
- package/dist/native/CameraView.android.js +25 -1
- package/dist/native/CameraView.ios.js +24 -1
- package/dist/native/CameraView.windows.js +173 -0
- package/dist/native/camera-permission.windows.js +43 -0
- package/dist/native/camera-session.windows.js +78 -0
- package/dist/native/cx.mobile.js +3 -2
- package/dist/native/cx.windows.js +9 -0
- package/dist/native/index.js +4 -1
- package/dist/native/layout-child.windows.js +53 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/session-backend.android.js +234 -0
- package/dist/native/session-backend.ios.js +682 -0
- package/dist/native/session-backend.js +55 -0
- package/dist/native/session-backend.windows.js +905 -0
- package/dist/native/session-core.js +368 -0
- package/dist/native/session.js +29 -0
- package/dist/native/synthetic-session.js +280 -0
- package/dist/native/types.js +19 -0
- package/dist/web/CameraView.web.tsrx.js +28 -8
- package/dist/web/host-movie.web.js +100 -0
- package/dist/web/index.js +4 -1
- package/dist/web/movie-metadata.web.js +65 -0
- package/dist/web/movie-storage.web.js +119 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/session-backend.web.js +490 -0
- package/dist/web/session-core.js +368 -0
- package/dist/web/session.js +29 -0
- package/dist/web/synthetic-session.js +280 -0
- package/dist/web/types.js +19 -0
- package/package.json +23 -5
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
- package/platforms/ios/Info.plist +2 -0
- package/platforms/macos/src/XplatCameraHost.swift +513 -0
- package/src/CameraView.android.tsrx +21 -1
- package/src/CameraView.ios.tsrx +28 -2
- package/src/CameraView.macos.tsrx +93 -0
- package/src/CameraView.web.tsrx +20 -2
- package/src/CameraView.windows.tsrx +124 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +123 -0
- package/src/cx.macos.ts +5 -0
- package/src/cx.mobile.ts +3 -2
- package/src/cx.windows.ts +6 -0
- package/src/host-movie.web.ts +180 -0
- package/src/index.ts +31 -0
- package/src/index.web.ts +31 -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/props.ts +7 -1
- package/src/session-backend.android.ts +397 -0
- package/src/session-backend.ios.ts +1092 -0
- package/src/session-backend.linux.ts +869 -0
- package/src/session-backend.macos.ts +624 -0
- package/src/session-backend.ts +57 -0
- package/src/session-backend.web.ts +874 -0
- package/src/session-backend.windows.ts +1517 -0
- package/src/session-core.ts +659 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +367 -0
- package/src/types.ts +329 -0
- package/types/index.d.ts +43 -0
- package/types/props.d.ts +7 -1
- package/types/session-core.d.ts +129 -0
- package/types/synthetic-session.d.ts +48 -0
- package/types/types.d.ts +263 -0
- package/src/CameraView.windows.ts +0 -5
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import '@nativescript/macos-node-api'
|
|
2
|
+
|
|
3
|
+
import { CameraCaptureError } from './types'
|
|
4
|
+
import type { BackendCaptureRequest, BackendCaptureSink, BackendObserver } from './session-core'
|
|
5
|
+
import type { CameraSessionBackend } from './session-core'
|
|
6
|
+
import type { BackendPreviewListener } from './session-core'
|
|
7
|
+
import type {
|
|
8
|
+
CameraDeviceInfo,
|
|
9
|
+
CameraOrientation,
|
|
10
|
+
CameraPermissionKind,
|
|
11
|
+
CameraPermissionStatus,
|
|
12
|
+
CameraSessionCapabilities,
|
|
13
|
+
CameraSessionConfig,
|
|
14
|
+
CaptureIssue,
|
|
15
|
+
EffectiveCaptureConfig,
|
|
16
|
+
MovieOutputHandle,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
declare const NSFileManager: any
|
|
20
|
+
|
|
21
|
+
interface CameraHost {
|
|
22
|
+
attachPreview(view: object): string | null
|
|
23
|
+
detachPreview(): void
|
|
24
|
+
installObserver(emit: (message: string) => void): void
|
|
25
|
+
permissionStatus(kind: string): string
|
|
26
|
+
requestPermissionReply(kind: string, reply: (status: string) => void): void
|
|
27
|
+
devices(): string
|
|
28
|
+
audioDeviceAvailable(): boolean
|
|
29
|
+
audioInputAttached(): boolean
|
|
30
|
+
activeDeviceId(): string | null
|
|
31
|
+
configure(options: { cameraId?: string; audio?: boolean; rotationAngle?: number }): string | null
|
|
32
|
+
supportsRotationAngles(): boolean
|
|
33
|
+
movieDirectory(): string | null
|
|
34
|
+
startRecording(path: string, maximumDurationMs: number): string | null
|
|
35
|
+
stopRecording(): void
|
|
36
|
+
recording(): boolean
|
|
37
|
+
recordedMs(): number
|
|
38
|
+
previewSize(): string
|
|
39
|
+
dispose(): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare const XplatCameraHost: {
|
|
43
|
+
alloc(): { init(): CameraHost }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type HostEvent =
|
|
47
|
+
| { type: 'recordingStarted'; fileUrl: string; audio: boolean }
|
|
48
|
+
| {
|
|
49
|
+
type: 'recordingFinished'
|
|
50
|
+
fileUrl: string
|
|
51
|
+
succeeded: boolean
|
|
52
|
+
limitReached: boolean
|
|
53
|
+
clip?: {
|
|
54
|
+
fileUrl: string
|
|
55
|
+
durationMs: number
|
|
56
|
+
width: number
|
|
57
|
+
height: number
|
|
58
|
+
rotationAngle: number
|
|
59
|
+
fileSize: number
|
|
60
|
+
hasAudio: boolean
|
|
61
|
+
videoCodec?: string
|
|
62
|
+
}
|
|
63
|
+
error?: { domain: string; code: number; message: string }
|
|
64
|
+
}
|
|
65
|
+
| { type: 'interrupted'; cause: string }
|
|
66
|
+
| { type: 'interruptionEnded' }
|
|
67
|
+
| { type: 'availability'; available: boolean }
|
|
68
|
+
| { type: 'previewReady'; ready: boolean }
|
|
69
|
+
|
|
70
|
+
const orientationToRotationAngle = (orientation: CameraOrientation): number | undefined =>
|
|
71
|
+
orientation === 'portrait'
|
|
72
|
+
? 90
|
|
73
|
+
: orientation === 'portraitUpsideDown'
|
|
74
|
+
? 270
|
|
75
|
+
: orientation === 'landscapeLeft'
|
|
76
|
+
? 180
|
|
77
|
+
: orientation === 'landscapeRight'
|
|
78
|
+
? 0
|
|
79
|
+
: undefined
|
|
80
|
+
|
|
81
|
+
const orientationFromRotationAngle = (angle: number): CameraOrientation =>
|
|
82
|
+
angle === 90
|
|
83
|
+
? 'portrait'
|
|
84
|
+
: angle === 270
|
|
85
|
+
? 'portraitUpsideDown'
|
|
86
|
+
: angle === 180
|
|
87
|
+
? 'landscapeLeft'
|
|
88
|
+
: angle === 0
|
|
89
|
+
? 'landscapeRight'
|
|
90
|
+
: 'unspecified'
|
|
91
|
+
|
|
92
|
+
const usageKey = (kind: CameraPermissionKind) =>
|
|
93
|
+
kind === 'camera' ? 'NSCameraUsageDescription' : 'NSMicrophoneUsageDescription'
|
|
94
|
+
|
|
95
|
+
/** AppKit adapter: one XplatCameraHost (AVCaptureSession) shared between the
|
|
96
|
+
* attached preview and a single AVCaptureMovieFileOutput capture attempt.
|
|
97
|
+
* The Swift leaf owns the session, inputs, preview layer, and recording
|
|
98
|
+
* delegate; this adapter owns admission, semantics, and contract mapping. */
|
|
99
|
+
export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
|
|
100
|
+
const host = XplatCameraHost.alloc().init()
|
|
101
|
+
let currentConfig = { ...config }
|
|
102
|
+
let previewReadyFlag = false
|
|
103
|
+
let observer: BackendObserver | undefined
|
|
104
|
+
let captureSink: BackendCaptureSink | undefined
|
|
105
|
+
let captureRequest: BackendCaptureRequest | undefined
|
|
106
|
+
let captureFileUrl = ''
|
|
107
|
+
let released = false
|
|
108
|
+
|
|
109
|
+
const emitIssue = (
|
|
110
|
+
kind: CaptureIssue['kind'],
|
|
111
|
+
operation: string,
|
|
112
|
+
message: string,
|
|
113
|
+
cause?: unknown,
|
|
114
|
+
): CaptureIssue => ({ kind, operation, message, cause })
|
|
115
|
+
|
|
116
|
+
const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
|
|
117
|
+
new CameraCaptureError({ kind, operation, message, cause })
|
|
118
|
+
|
|
119
|
+
const devices = (): CameraDeviceInfo[] => {
|
|
120
|
+
try {
|
|
121
|
+
return (
|
|
122
|
+
JSON.parse(host.devices()) as {
|
|
123
|
+
id: string
|
|
124
|
+
name: string
|
|
125
|
+
facing?: 'front' | 'back' | null
|
|
126
|
+
}[]
|
|
127
|
+
).map((entry) => ({
|
|
128
|
+
id: entry.id,
|
|
129
|
+
label: entry.name,
|
|
130
|
+
facing: entry.facing ?? undefined,
|
|
131
|
+
}))
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const previewSize = (): { width: number; height: number } | undefined => {
|
|
138
|
+
try {
|
|
139
|
+
const size = JSON.parse(host.previewSize())
|
|
140
|
+
return size.width > 0 ? { width: size.width, height: size.height } : undefined
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const selectedCameraId = (): string | undefined => {
|
|
147
|
+
const selection = currentConfig.camera
|
|
148
|
+
if (selection && typeof selection === 'object' && 'deviceId' in selection) {
|
|
149
|
+
return selection.deviceId
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 'default'/'front'/'back' resolve to the first available device —
|
|
153
|
+
// macOS cameras carry no front/back facing semantics.
|
|
154
|
+
return devices()[0]?.id
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
host.installObserver((message) => {
|
|
158
|
+
let event: HostEvent
|
|
159
|
+
try {
|
|
160
|
+
event = JSON.parse(message)
|
|
161
|
+
} catch {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
switch (event.type) {
|
|
166
|
+
case 'recordingStarted': {
|
|
167
|
+
if (event.fileUrl !== captureFileUrl || !captureSink || !captureRequest) {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const size = previewSize()
|
|
172
|
+
const configuration: EffectiveCaptureConfig = {
|
|
173
|
+
cameraId: host.activeDeviceId() ?? selectedCameraId() ?? '',
|
|
174
|
+
audio: event.audio,
|
|
175
|
+
width: size?.width ?? 1280,
|
|
176
|
+
height: size?.height ?? 720,
|
|
177
|
+
frameRate: 30,
|
|
178
|
+
orientation: captureRequest.orientation ?? 'unspecified',
|
|
179
|
+
mimeType: 'video/quicktime',
|
|
180
|
+
container: 'mov',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
captureSink.started(configuration)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
case 'recordingFinished': {
|
|
187
|
+
if (event.fileUrl !== captureFileUrl || !captureSink || !captureRequest) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sink = captureSink
|
|
192
|
+
const request = captureRequest
|
|
193
|
+
captureSink = undefined
|
|
194
|
+
captureRequest = undefined
|
|
195
|
+
|
|
196
|
+
if (!event.succeeded || !event.clip) {
|
|
197
|
+
sink.settled({
|
|
198
|
+
kind: 'failed',
|
|
199
|
+
stage: 'capture',
|
|
200
|
+
issue: emitIssue(
|
|
201
|
+
'captureFailed',
|
|
202
|
+
'finalize',
|
|
203
|
+
event.error?.message ?? 'Movie recording did not complete',
|
|
204
|
+
),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (request.audio && !event.clip.hasAudio) {
|
|
211
|
+
sink.settled({
|
|
212
|
+
kind: 'failed',
|
|
213
|
+
stage: 'capture',
|
|
214
|
+
issue: emitIssue(
|
|
215
|
+
'captureFailed',
|
|
216
|
+
'finalize',
|
|
217
|
+
'Requested audio track is missing from the finalized movie',
|
|
218
|
+
),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
sink.settled({
|
|
225
|
+
kind: 'clip',
|
|
226
|
+
output: {
|
|
227
|
+
kind: 'nativeFile',
|
|
228
|
+
resourceId: event.fileUrl,
|
|
229
|
+
fileUrl: event.fileUrl,
|
|
230
|
+
},
|
|
231
|
+
mimeType: 'video/quicktime',
|
|
232
|
+
durationMs: event.clip.durationMs,
|
|
233
|
+
width: event.clip.width,
|
|
234
|
+
height: event.clip.height,
|
|
235
|
+
orientation:
|
|
236
|
+
request.orientation ?? orientationFromRotationAngle(event.clip.rotationAngle),
|
|
237
|
+
hasAudio: request.audio && event.clip.hasAudio,
|
|
238
|
+
limitReached: event.limitReached,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
case 'interrupted':
|
|
244
|
+
observer?.interruption(
|
|
245
|
+
'began',
|
|
246
|
+
(event.cause as Parameters<BackendObserver['interruption']>[1]) ?? 'unknown',
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
observer?.previewReady(previewReadyFlag)
|
|
250
|
+
return
|
|
251
|
+
case 'interruptionEnded':
|
|
252
|
+
observer?.interruption('ended')
|
|
253
|
+
observer?.previewReady(previewReadyFlag)
|
|
254
|
+
return
|
|
255
|
+
case 'availability':
|
|
256
|
+
observer?.availability(event.available)
|
|
257
|
+
return
|
|
258
|
+
case 'previewReady':
|
|
259
|
+
previewReadyFlag = event.ready
|
|
260
|
+
observer?.previewReady(previewReadyFlag)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const checkPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
266
|
+
const status = host.permissionStatus(kind)
|
|
267
|
+
return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const requestPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
271
|
+
if (host.permissionStatus(kind) === 'undeclared') {
|
|
272
|
+
throw error(
|
|
273
|
+
'configurationMissing',
|
|
274
|
+
'requestPermission',
|
|
275
|
+
`Missing ${usageKey(kind)} in the app Info.plist`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const status = await new Promise<string>((resolve) =>
|
|
280
|
+
host.requestPermissionReply(kind, resolve),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return (status === 'undeclared' ? 'unknown' : status) as CameraPermissionStatus
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const recordingsDirectory = (): string => {
|
|
287
|
+
const root = host.movieDirectory()
|
|
288
|
+
if (!root) {
|
|
289
|
+
throw error(
|
|
290
|
+
'destinationUnavailable',
|
|
291
|
+
'startRecording',
|
|
292
|
+
'The app has no writable private camera directory',
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return decodeURIComponent(root.replace(/^file:\/\//, '').replace(/\/$/, ''))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Durable output must stay app-private: destinations resolve inside the
|
|
300
|
+
// app's own Application Support root — the recordings directory's parent.
|
|
301
|
+
const appPrivateRoot = () =>
|
|
302
|
+
recordingsDirectory().slice(0, recordingsDirectory().lastIndexOf('/'))
|
|
303
|
+
|
|
304
|
+
const pathExists = (path: string): boolean =>
|
|
305
|
+
Boolean(NSFileManager.defaultManager.fileExistsAtPath(path))
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
platform: 'macos',
|
|
309
|
+
get supported() {
|
|
310
|
+
return !released
|
|
311
|
+
},
|
|
312
|
+
checkPermission,
|
|
313
|
+
requestPermission,
|
|
314
|
+
getCapabilities: async (): Promise<CameraSessionCapabilities> => {
|
|
315
|
+
const list = devices()
|
|
316
|
+
const available = list.length > 0
|
|
317
|
+
const size = previewSize()
|
|
318
|
+
return {
|
|
319
|
+
supported: available,
|
|
320
|
+
reason: available ? undefined : 'No camera device is available on this host',
|
|
321
|
+
available,
|
|
322
|
+
cameras: list,
|
|
323
|
+
profiles: [
|
|
324
|
+
{
|
|
325
|
+
profile: 'standard',
|
|
326
|
+
width: size?.width ?? 1280,
|
|
327
|
+
height: size?.height ?? 720,
|
|
328
|
+
frameRate: 30,
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
audio: host.audioDeviceAvailable(),
|
|
332
|
+
// AVCaptureConnection rotation angles exist on macOS 14+;
|
|
333
|
+
// earlier hosts cannot lock a take's orientation.
|
|
334
|
+
orientations: host.supportsRotationAngles()
|
|
335
|
+
? ['landscapeLeft', 'landscapeRight', 'portrait', 'portraitUpsideDown']
|
|
336
|
+
: [],
|
|
337
|
+
durationLimit: 'native',
|
|
338
|
+
elapsedTime: 'nativeMedia',
|
|
339
|
+
output: {
|
|
340
|
+
mimeType: 'video/quicktime',
|
|
341
|
+
container: 'mov',
|
|
342
|
+
storage: 'appPrivateFile',
|
|
343
|
+
destinationFileUrl: true,
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
previewReady: () => previewReadyFlag,
|
|
348
|
+
attachPreview: (target, listener?: BackendPreviewListener) => {
|
|
349
|
+
if (released) {
|
|
350
|
+
throw error('unavailable', 'attachPreview', 'CameraSession is disposed')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (host.permissionStatus('camera') !== 'granted') {
|
|
354
|
+
throw error(
|
|
355
|
+
'permissionDenied',
|
|
356
|
+
'attachPreview',
|
|
357
|
+
'Camera preview requires an explicit granted camera permission',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!devices().length) {
|
|
362
|
+
throw error('unavailable', 'attachPreview', 'No camera device is available on this host')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const configured = host.configure({
|
|
366
|
+
cameraId: selectedCameraId(),
|
|
367
|
+
audio: currentConfig.audio ?? false,
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
if (configured) {
|
|
371
|
+
throw error(
|
|
372
|
+
configured === 'permissionDenied' ? 'permissionDenied' : 'unavailable',
|
|
373
|
+
'attachPreview',
|
|
374
|
+
`Failed to configure the capture session: ${configured}`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const failure = host.attachPreview(target as object)
|
|
379
|
+
if (failure) {
|
|
380
|
+
throw error(
|
|
381
|
+
failure === 'permissionDenied' ? 'permissionDenied' : 'unavailable',
|
|
382
|
+
'attachPreview',
|
|
383
|
+
`Failed to start camera preview: ${failure}`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
listener?.onReady?.()
|
|
388
|
+
return () => {
|
|
389
|
+
previewReadyFlag = false
|
|
390
|
+
observer?.previewReady(false)
|
|
391
|
+
host.detachPreview()
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
configure: (next) => {
|
|
395
|
+
currentConfig = { ...next }
|
|
396
|
+
const configured = host.configure({
|
|
397
|
+
cameraId: selectedCameraId(),
|
|
398
|
+
audio: next.audio ?? false,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
if (configured === 'unavailable') {
|
|
402
|
+
throw error('unavailable', 'configure', 'The requested camera device is not available')
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
startCapture: (request, sink) => {
|
|
406
|
+
if (host.permissionStatus('camera') === 'undeclared') {
|
|
407
|
+
sink.settled({
|
|
408
|
+
kind: 'failed',
|
|
409
|
+
stage: 'preparation',
|
|
410
|
+
issue: emitIssue(
|
|
411
|
+
'configurationMissing',
|
|
412
|
+
'startRecording',
|
|
413
|
+
"Missing NSCameraUsageDescription in the host app's Info.plist",
|
|
414
|
+
),
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (request.audio) {
|
|
421
|
+
if (host.permissionStatus('microphone') === 'undeclared') {
|
|
422
|
+
sink.settled({
|
|
423
|
+
kind: 'failed',
|
|
424
|
+
stage: 'preparation',
|
|
425
|
+
issue: emitIssue(
|
|
426
|
+
'configurationMissing',
|
|
427
|
+
'startRecording',
|
|
428
|
+
"Missing NSMicrophoneUsageDescription in the host app's Info.plist",
|
|
429
|
+
),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const mic = host.permissionStatus('microphone')
|
|
436
|
+
if (mic !== 'granted') {
|
|
437
|
+
sink.settled({
|
|
438
|
+
kind: 'failed',
|
|
439
|
+
stage: 'preparation',
|
|
440
|
+
issue: emitIssue(
|
|
441
|
+
mic === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
|
|
442
|
+
'startRecording',
|
|
443
|
+
`Microphone permission is ${mic}`,
|
|
444
|
+
),
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!host.audioDeviceAvailable()) {
|
|
451
|
+
sink.settled({
|
|
452
|
+
kind: 'failed',
|
|
453
|
+
stage: 'preparation',
|
|
454
|
+
issue: emitIssue('unavailable', 'startRecording', 'No audio input device is available'),
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const rotationAngle =
|
|
462
|
+
request.orientation !== undefined
|
|
463
|
+
? orientationToRotationAngle(request.orientation)
|
|
464
|
+
: undefined
|
|
465
|
+
|
|
466
|
+
if (request.orientation !== undefined && rotationAngle === undefined) {
|
|
467
|
+
sink.settled({
|
|
468
|
+
kind: 'failed',
|
|
469
|
+
stage: 'preparation',
|
|
470
|
+
issue: emitIssue(
|
|
471
|
+
'unsupportedConfiguration',
|
|
472
|
+
'startRecording',
|
|
473
|
+
`Orientation '${request.orientation}' is not supported by this connection`,
|
|
474
|
+
),
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const configured = host.configure({
|
|
481
|
+
cameraId: selectedCameraId(),
|
|
482
|
+
audio: request.audio || (currentConfig.audio ?? false),
|
|
483
|
+
rotationAngle,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
if (configured) {
|
|
487
|
+
sink.settled({
|
|
488
|
+
kind: 'failed',
|
|
489
|
+
stage: 'preparation',
|
|
490
|
+
issue: emitIssue(
|
|
491
|
+
configured === 'unsupportedConfiguration' ? 'unsupportedConfiguration' : 'unavailable',
|
|
492
|
+
'startRecording',
|
|
493
|
+
configured === 'unsupportedConfiguration'
|
|
494
|
+
? `Orientation '${request.orientation}' is not supported by this connection`
|
|
495
|
+
: 'The requested capture configuration is unavailable',
|
|
496
|
+
),
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let filePath: string
|
|
503
|
+
if (request.destinationPath) {
|
|
504
|
+
const inside = request.destinationPath.startsWith(`${appPrivateRoot()}/`)
|
|
505
|
+
const directory = request.destinationPath.slice(0, request.destinationPath.lastIndexOf('/'))
|
|
506
|
+
|
|
507
|
+
const directoryExists = inside && pathExists(directory)
|
|
508
|
+
const fileExists = inside && pathExists(request.destinationPath)
|
|
509
|
+
if (!inside || !directoryExists || fileExists) {
|
|
510
|
+
sink.settled({
|
|
511
|
+
kind: 'failed',
|
|
512
|
+
stage: 'preparation',
|
|
513
|
+
issue: emitIssue(
|
|
514
|
+
'destinationUnavailable',
|
|
515
|
+
'startRecording',
|
|
516
|
+
!inside
|
|
517
|
+
? 'destinationFileUrl must resolve inside the app-private recordings directory'
|
|
518
|
+
: fileExists
|
|
519
|
+
? 'destinationFileUrl already exists'
|
|
520
|
+
: 'destinationFileUrl directory does not exist',
|
|
521
|
+
),
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!/\.mov$/i.test(request.destinationPath)) {
|
|
528
|
+
sink.settled({
|
|
529
|
+
kind: 'failed',
|
|
530
|
+
stage: 'preparation',
|
|
531
|
+
issue: emitIssue(
|
|
532
|
+
'destinationUnavailable',
|
|
533
|
+
'startRecording',
|
|
534
|
+
'destinationFileUrl must use a .mov file name',
|
|
535
|
+
),
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
filePath = request.destinationPath
|
|
542
|
+
} else {
|
|
543
|
+
try {
|
|
544
|
+
filePath = `${recordingsDirectory()}/clip-${Date.now()}-${Math.random()
|
|
545
|
+
.toString(36)
|
|
546
|
+
.slice(2, 8)}.mov`
|
|
547
|
+
} catch (cause) {
|
|
548
|
+
sink.settled({
|
|
549
|
+
kind: 'failed',
|
|
550
|
+
stage: 'preparation',
|
|
551
|
+
issue: emitIssue(
|
|
552
|
+
'destinationUnavailable',
|
|
553
|
+
'startRecording',
|
|
554
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
555
|
+
cause,
|
|
556
|
+
),
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
captureFileUrl = `file://${filePath.split('/').map(encodeURIComponent).join('/')}`
|
|
564
|
+
|
|
565
|
+
captureSink = sink
|
|
566
|
+
captureRequest = request
|
|
567
|
+
const failure = host.startRecording(filePath, request.maximumDurationMs ?? 0)
|
|
568
|
+
if (failure) {
|
|
569
|
+
captureSink = undefined
|
|
570
|
+
captureRequest = undefined
|
|
571
|
+
captureFileUrl = ''
|
|
572
|
+
sink.settled({
|
|
573
|
+
kind: 'failed',
|
|
574
|
+
stage: 'preparation',
|
|
575
|
+
issue: emitIssue(
|
|
576
|
+
'unavailable',
|
|
577
|
+
'startRecording',
|
|
578
|
+
`Failed to start movie capture: ${failure}`,
|
|
579
|
+
),
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
stopCapture: () => {
|
|
584
|
+
host.stopRecording()
|
|
585
|
+
},
|
|
586
|
+
elapsedMs: () => host.recordedMs(),
|
|
587
|
+
openOutput: async (output) => {
|
|
588
|
+
if (output.kind !== 'nativeFile' || !output.fileUrl) {
|
|
589
|
+
throw error(
|
|
590
|
+
'invalidArgument',
|
|
591
|
+
'openOutput',
|
|
592
|
+
'Only file-backed output can be opened on macOS',
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const filePath = decodeURIComponent(output.fileUrl.slice('file://'.length))
|
|
597
|
+
if (!pathExists(filePath)) {
|
|
598
|
+
throw error('unavailable', 'openOutput', `Movie file is missing at ${output.fileUrl}`)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const handle: MovieOutputHandle = {
|
|
602
|
+
url: output.fileUrl,
|
|
603
|
+
fileUrl: output.fileUrl,
|
|
604
|
+
release: () => {},
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return handle
|
|
608
|
+
},
|
|
609
|
+
release: async () => {
|
|
610
|
+
released = true
|
|
611
|
+
previewReadyFlag = false
|
|
612
|
+
captureSink = undefined
|
|
613
|
+
captureRequest = undefined
|
|
614
|
+
host.dispose()
|
|
615
|
+
},
|
|
616
|
+
onPreviewDetached: () => {
|
|
617
|
+
// The view went away while a take settles; the Swift side keeps the
|
|
618
|
+
// session alive until recordingFinished arrives.
|
|
619
|
+
},
|
|
620
|
+
setObserver: (next) => {
|
|
621
|
+
observer = next
|
|
622
|
+
},
|
|
623
|
+
}
|
|
624
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CameraCaptureError } from './types'
|
|
2
|
+
import type { CameraSessionBackend } from './session-core'
|
|
3
|
+
import type { CameraSessionConfig } from './types'
|
|
4
|
+
|
|
5
|
+
/** Default adapter for platforms without a movie-capture implementation
|
|
6
|
+
* yet. Honest `unsupported` reporting — never fabricated capture. Kept
|
|
7
|
+
* platform-pure so web and native fallback builds share it. */
|
|
8
|
+
export function createSessionBackend(_config: CameraSessionConfig): CameraSessionBackend {
|
|
9
|
+
const unsupported = (operation: string) =>
|
|
10
|
+
new CameraCaptureError({
|
|
11
|
+
kind: 'unsupportedPlatform',
|
|
12
|
+
operation,
|
|
13
|
+
message: 'Movie recording is not implemented on this platform yet',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
platform: 'unsupported',
|
|
18
|
+
supported: false,
|
|
19
|
+
checkPermission: async () => 'unavailable',
|
|
20
|
+
requestPermission: async () => 'unavailable',
|
|
21
|
+
getCapabilities: async () => ({
|
|
22
|
+
supported: false,
|
|
23
|
+
reason: 'Movie recording is not implemented on this platform yet',
|
|
24
|
+
available: false,
|
|
25
|
+
cameras: [],
|
|
26
|
+
profiles: [],
|
|
27
|
+
audio: false,
|
|
28
|
+
orientations: [],
|
|
29
|
+
durationLimit: 'bestEffort',
|
|
30
|
+
elapsedTime: 'estimated',
|
|
31
|
+
output: {
|
|
32
|
+
mimeType: '',
|
|
33
|
+
container: '',
|
|
34
|
+
storage: 'appPrivateFile',
|
|
35
|
+
destinationFileUrl: false,
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
previewReady: () => false,
|
|
39
|
+
attachPreview: () => {
|
|
40
|
+
throw unsupported('attachPreview')
|
|
41
|
+
},
|
|
42
|
+
startCapture: (_request, sink) => {
|
|
43
|
+
sink.settled({
|
|
44
|
+
kind: 'failed',
|
|
45
|
+
stage: 'preparation',
|
|
46
|
+
issue: unsupported('startRecording'),
|
|
47
|
+
})
|
|
48
|
+
},
|
|
49
|
+
stopCapture: () => {},
|
|
50
|
+
elapsedMs: () => 0,
|
|
51
|
+
openOutput: async () => {
|
|
52
|
+
throw unsupported('openOutput')
|
|
53
|
+
},
|
|
54
|
+
release: async () => {},
|
|
55
|
+
setObserver: () => {},
|
|
56
|
+
}
|
|
57
|
+
}
|