@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,1517 @@
|
|
|
1
|
+
import { Application } from '@nativescript/core'
|
|
2
|
+
import { CameraCaptureError } from './types'
|
|
3
|
+
import type {
|
|
4
|
+
BackendCaptureRequest,
|
|
5
|
+
BackendCaptureSink,
|
|
6
|
+
BackendObserver,
|
|
7
|
+
BackendPreviewListener,
|
|
8
|
+
CameraSessionBackend,
|
|
9
|
+
} from './session-core'
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CameraDeviceInfo,
|
|
13
|
+
CameraOrientation,
|
|
14
|
+
CameraPermissionKind,
|
|
15
|
+
CameraPermissionStatus,
|
|
16
|
+
CameraSessionConfig,
|
|
17
|
+
CaptureIssue,
|
|
18
|
+
EffectiveCaptureConfig,
|
|
19
|
+
MovieOutputHandle,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
/** Windows adapter: one `Windows.Media.Capture.MediaCapture` shared between a
|
|
23
|
+
* `Microsoft.UI.Xaml.Controls.CaptureElement` preview and a single
|
|
24
|
+
* `StartRecordToStorageFileAsync` capture attempt. Everything reaches WinRT
|
|
25
|
+
* through the runtime's dynamic projection (`Windows`/`Microsoft`/`NSWinRT`
|
|
26
|
+
* globals), so every call site is guarded — a projection miss reports
|
|
27
|
+
* `unsupported`/`unavailable`, never fabricated capture. */
|
|
28
|
+
|
|
29
|
+
// Windows.Devices.Enumeration.DeviceAccessStatus
|
|
30
|
+
const deviceAccessUnspecified = 0
|
|
31
|
+
const deviceAccessAllowed = 1
|
|
32
|
+
const deviceAccessDeniedByUser = 2
|
|
33
|
+
const deviceAccessDeniedBySystem = 3
|
|
34
|
+
|
|
35
|
+
// Windows.Security.Authorization.AppCapabilityAccess.AppCapabilityAccessStatus
|
|
36
|
+
const appCapabilityDeniedBySystem = 0
|
|
37
|
+
const appCapabilityNotDeclaredByApp = 1
|
|
38
|
+
const appCapabilityDeniedByUser = 2
|
|
39
|
+
const appCapabilityUserPromptRequired = 3
|
|
40
|
+
const appCapabilityAllowed = 4
|
|
41
|
+
|
|
42
|
+
// Windows.Devices.Enumeration.Panel
|
|
43
|
+
const panelFront = 1
|
|
44
|
+
const panelBack = 2
|
|
45
|
+
|
|
46
|
+
// Windows.Media.Capture.StreamingCaptureMode
|
|
47
|
+
const captureModeVideo = 0
|
|
48
|
+
const captureModeAudioAndVideo = 2
|
|
49
|
+
|
|
50
|
+
// Windows.Media.Capture.MediaCaptureRotation
|
|
51
|
+
const recordRotationNone = 0
|
|
52
|
+
const recordRotation90 = 1
|
|
53
|
+
const recordRotation180 = 2
|
|
54
|
+
const recordRotation270 = 3
|
|
55
|
+
|
|
56
|
+
// Windows.Storage.CreationCollisionOption
|
|
57
|
+
const collisionGenerateUniqueName = 0
|
|
58
|
+
const collisionFailIfExists = 1
|
|
59
|
+
const collisionOpenIfExists = 2
|
|
60
|
+
|
|
61
|
+
// Windows.Storage.FileProperties.VideoOrientation — stored display rotation
|
|
62
|
+
const fileOrientationNormal = 0
|
|
63
|
+
const fileOrientation90 = 90
|
|
64
|
+
const fileOrientation180 = 180
|
|
65
|
+
const fileOrientation270 = 270
|
|
66
|
+
|
|
67
|
+
const capabilityNameFor = (kind: CameraPermissionKind): string =>
|
|
68
|
+
kind === 'camera' ? 'webcam' : 'microphone'
|
|
69
|
+
|
|
70
|
+
const minFreeDiskBytes = 64 * 1024 * 1024
|
|
71
|
+
|
|
72
|
+
const projection = (): { Windows?: any; Microsoft?: any; NSWinRT?: any } => {
|
|
73
|
+
const host = globalThis as any
|
|
74
|
+
return { Windows: host.Windows, Microsoft: host.Microsoft, NSWinRT: host.NSWinRT }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
|
|
78
|
+
new CameraCaptureError({ kind, operation, message, cause })
|
|
79
|
+
|
|
80
|
+
const issue = (
|
|
81
|
+
kind: CaptureIssue['kind'],
|
|
82
|
+
operation: string,
|
|
83
|
+
message: string,
|
|
84
|
+
extra?: Partial<CaptureIssue>,
|
|
85
|
+
): CaptureIssue => ({ kind, operation, message, ...extra })
|
|
86
|
+
|
|
87
|
+
/** AppCapabilityAccessStatus → portable permission. `notDeclared` is a
|
|
88
|
+
* setup error, not a status — callers surface `configurationMissing`. */
|
|
89
|
+
export const accessStatusToPermission = (
|
|
90
|
+
status: number | undefined,
|
|
91
|
+
): CameraPermissionStatus | 'notDeclared' => {
|
|
92
|
+
switch (status) {
|
|
93
|
+
case appCapabilityAllowed:
|
|
94
|
+
return 'granted'
|
|
95
|
+
case appCapabilityDeniedByUser:
|
|
96
|
+
return 'denied'
|
|
97
|
+
case appCapabilityDeniedBySystem:
|
|
98
|
+
return 'restricted'
|
|
99
|
+
case appCapabilityUserPromptRequired:
|
|
100
|
+
return 'notDetermined'
|
|
101
|
+
case appCapabilityNotDeclaredByApp:
|
|
102
|
+
return 'notDeclared'
|
|
103
|
+
default:
|
|
104
|
+
return 'unknown'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** DeviceAccessStatus → portable permission for hosts without AppCapability. */
|
|
109
|
+
export const deviceAccessStatusToPermission = (
|
|
110
|
+
status: number | undefined,
|
|
111
|
+
): CameraPermissionStatus => {
|
|
112
|
+
switch (status) {
|
|
113
|
+
case deviceAccessAllowed:
|
|
114
|
+
return 'granted'
|
|
115
|
+
case deviceAccessDeniedByUser:
|
|
116
|
+
return 'denied'
|
|
117
|
+
case deviceAccessDeniedBySystem:
|
|
118
|
+
return 'restricted'
|
|
119
|
+
case deviceAccessUnspecified:
|
|
120
|
+
return 'notDetermined'
|
|
121
|
+
default:
|
|
122
|
+
return 'unknown'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Enclosure panel → facing. Desktop cameras often report no panel — an
|
|
127
|
+
* external webcam never fabricates front/back semantics. */
|
|
128
|
+
export const panelToFacing = (panel: number | undefined): 'front' | 'back' | undefined =>
|
|
129
|
+
panel === panelFront ? 'front' : panel === panelBack ? 'back' : undefined
|
|
130
|
+
|
|
131
|
+
/** Stored display rotation (degrees CW) → cardinal orientation, matching the
|
|
132
|
+
* iOS transform mapping. Missing or unrecognized values stay `unspecified`. */
|
|
133
|
+
export const videoOrientationToOrientation = (degrees: number | undefined): CameraOrientation => {
|
|
134
|
+
switch (degrees) {
|
|
135
|
+
case fileOrientation90:
|
|
136
|
+
return 'portrait'
|
|
137
|
+
case fileOrientation180:
|
|
138
|
+
return 'landscapeLeft'
|
|
139
|
+
case fileOrientation270:
|
|
140
|
+
return 'portraitUpsideDown'
|
|
141
|
+
case fileOrientationNormal:
|
|
142
|
+
return 'landscapeRight'
|
|
143
|
+
default:
|
|
144
|
+
return 'unspecified'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const orientationToRotation = (orientation: CameraOrientation): number | undefined =>
|
|
149
|
+
orientation === 'portrait'
|
|
150
|
+
? recordRotation90
|
|
151
|
+
: orientation === 'portraitUpsideDown'
|
|
152
|
+
? recordRotation270
|
|
153
|
+
: orientation === 'landscapeLeft'
|
|
154
|
+
? recordRotation180
|
|
155
|
+
: orientation === 'landscapeRight'
|
|
156
|
+
? recordRotationNone
|
|
157
|
+
: undefined
|
|
158
|
+
|
|
159
|
+
export interface WindowsCameraDevice {
|
|
160
|
+
id: string
|
|
161
|
+
label: string
|
|
162
|
+
facing?: 'front' | 'back'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Camera selection: explicit deviceId wins; front/back match the enclosure
|
|
166
|
+
* panel; `default` takes the OS-preferred camera then the first device. */
|
|
167
|
+
export const selectCameraDevice = (
|
|
168
|
+
camera: CameraSessionConfig['camera'],
|
|
169
|
+
devices: WindowsCameraDevice[],
|
|
170
|
+
defaultId: string | undefined,
|
|
171
|
+
): WindowsCameraDevice | undefined => {
|
|
172
|
+
if (camera && typeof camera === 'object' && 'deviceId' in camera) {
|
|
173
|
+
return devices.find((entry) => entry.id === camera.deviceId)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (camera === 'front' || camera === 'back') {
|
|
177
|
+
return devices.find((entry) => entry.facing === camera)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
devices.find((entry) => entry.id === defaultId) ??
|
|
182
|
+
devices.find((entry) => entry.facing === 'back') ??
|
|
183
|
+
devices[0]
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface WindowsStreamProfile {
|
|
188
|
+
width: number
|
|
189
|
+
height: number
|
|
190
|
+
frameRate: number
|
|
191
|
+
subtype?: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Pick the standard profile: 1280x720 @ 30 fps when the source offers it,
|
|
195
|
+
* otherwise the smallest stream then lowest frame rate. The selected
|
|
196
|
+
* combination is disclosed — never an idealized 720p claim. */
|
|
197
|
+
export const chooseStandardProfile = (
|
|
198
|
+
profiles: WindowsStreamProfile[],
|
|
199
|
+
): WindowsStreamProfile | undefined => {
|
|
200
|
+
if (!profiles.length) {
|
|
201
|
+
return undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const exact = profiles.find(
|
|
205
|
+
(entry) => entry.width === 1280 && entry.height === 720 && Math.round(entry.frameRate) === 30,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if (exact) {
|
|
209
|
+
return exact
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return [...profiles].sort((a, b) => {
|
|
213
|
+
const areaDelta = a.width * a.height - b.width * b.height
|
|
214
|
+
if (areaDelta !== 0) {
|
|
215
|
+
return areaDelta
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return a.frameRate - b.frameRate
|
|
219
|
+
})[0]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Classify a native failure without depending on projected error shapes.
|
|
223
|
+
* HRESULTs surface as `number`/`hresult`; MediaCapture failures also carry a
|
|
224
|
+
* numeric `code`. */
|
|
225
|
+
export const captureFailureKind = (cause: unknown): CaptureIssue['kind'] => {
|
|
226
|
+
const code = Number(
|
|
227
|
+
(cause as any)?.number ?? (cause as any)?.hresult ?? (cause as any)?.code ?? Number.NaN,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const message = String((cause as any)?.message ?? cause ?? '').toLowerCase()
|
|
231
|
+
if (code === -2147024891 || /access is denied|unauthorizedaccess|e_accessdenied/.test(message)) {
|
|
232
|
+
return 'permissionDenied'
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (code === -2147024814 || /sharing violation|in use|0x800700aa/.test(message)) {
|
|
236
|
+
return 'unavailable'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (/no capture devices|device (is )?not (available|found|ready)|cannot find/.test(message)) {
|
|
240
|
+
return 'unavailable'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (/quota|disk|storage|no space|insufficient/.test(message)) {
|
|
244
|
+
return 'insufficientStorage'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return 'captureFailed'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** WinRT TimeSpan marshals as `{ Duration: bigint|number }` of 100-ns ticks;
|
|
251
|
+
* some projections surface the raw tick count. */
|
|
252
|
+
const timeSpanToMs = (value: unknown): number => {
|
|
253
|
+
const ticks =
|
|
254
|
+
typeof value === 'bigint'
|
|
255
|
+
? Number(value)
|
|
256
|
+
: typeof value === 'number'
|
|
257
|
+
? value
|
|
258
|
+
: Number((value as any)?.Duration ?? (value as any)?.duration ?? Number.NaN)
|
|
259
|
+
|
|
260
|
+
return Number.isFinite(ticks) ? ticks / 10_000 : Number.NaN
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const toPromise = async (op: any): Promise<any> => {
|
|
264
|
+
const { NSWinRT } = projection()
|
|
265
|
+
if (NSWinRT?.toPromise && op != null) {
|
|
266
|
+
return NSWinRT.toPromise(op)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Some preview builds expose `.done()` on the projected async op.
|
|
270
|
+
if (op?.done) {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
try {
|
|
273
|
+
op.done(resolve, reject)
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
reject(cause)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return op
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const vectorToArray = (items: any): any[] => {
|
|
284
|
+
const result: any[] = []
|
|
285
|
+
const size = Number(items?.Size ?? items?.Count ?? 0)
|
|
286
|
+
for (let index = 0; index < size; index += 1) {
|
|
287
|
+
result.push(items.GetAt(index))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return result
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The first backend that initializes a device claims it — a new owner
|
|
294
|
+
* cannot seize the same hardware while the previous one is finalizing. */
|
|
295
|
+
const claimedDevices = new Map<string, object>()
|
|
296
|
+
|
|
297
|
+
const normalizePath = (value: string): string => {
|
|
298
|
+
let next = value.replace(/\//g, '\\')
|
|
299
|
+
// file:///C:/dir/file.mp4 decodes to /C:/dir/file.mp4 — strip the leading
|
|
300
|
+
// separator before a drive letter.
|
|
301
|
+
next = next.replace(/^\\(?=[A-Za-z]:)/, '')
|
|
302
|
+
return next
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const fileUrlForPath = (value: string): string =>
|
|
306
|
+
`file:///${value.replace(/\\/g, '/').replace(/^\/+/, '')}`
|
|
307
|
+
|
|
308
|
+
export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
|
|
309
|
+
let currentConfig = { ...config }
|
|
310
|
+
let observer: BackendObserver | undefined
|
|
311
|
+
let host: any
|
|
312
|
+
let listener: BackendPreviewListener | undefined
|
|
313
|
+
let capture: any
|
|
314
|
+
let captureElement: any
|
|
315
|
+
let captureDevice: WindowsCameraDevice | undefined
|
|
316
|
+
let captureAudioMode = false
|
|
317
|
+
let previewReadyFlag = false
|
|
318
|
+
let interrupted = false
|
|
319
|
+
let released = false
|
|
320
|
+
let generation = 0
|
|
321
|
+
let watcher: any
|
|
322
|
+
let watcherDelegates: { event: string; delegate: any }[] = []
|
|
323
|
+
let captureDelegates: { event: string; delegate: any }[] = []
|
|
324
|
+
let sink: BackendCaptureSink | undefined
|
|
325
|
+
let request: BackendCaptureRequest | undefined
|
|
326
|
+
let recordFile: any
|
|
327
|
+
let recordStartedAt = 0
|
|
328
|
+
let wantStop = false
|
|
329
|
+
let finalizing = false
|
|
330
|
+
const lease = {}
|
|
331
|
+
|
|
332
|
+
const projectionAvailable = () => {
|
|
333
|
+
const { Windows, Microsoft } = projection()
|
|
334
|
+
return !!(
|
|
335
|
+
Windows?.Media?.Capture?.MediaCapture &&
|
|
336
|
+
Windows?.Media?.Capture?.MediaCaptureInitializationSettings &&
|
|
337
|
+
Windows?.Devices?.Enumeration?.DeviceInformation &&
|
|
338
|
+
Windows?.Storage?.ApplicationData &&
|
|
339
|
+
Microsoft?.UI?.Xaml?.Controls?.CaptureElement
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const markReady = (value: boolean) => {
|
|
344
|
+
if (previewReadyFlag === value) {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
previewReadyFlag = value
|
|
349
|
+
observer?.previewReady(value)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const appCapability = (kind: CameraPermissionKind): any => {
|
|
353
|
+
try {
|
|
354
|
+
return projection().Windows?.Security?.Authorization?.AppCapabilityAccess?.AppCapability?.Create(
|
|
355
|
+
capabilityNameFor(kind),
|
|
356
|
+
)
|
|
357
|
+
} catch {
|
|
358
|
+
return undefined
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const deviceAccessInfo = (kind: CameraPermissionKind): any => {
|
|
363
|
+
try {
|
|
364
|
+
const enumeration = projection().Windows?.Devices?.Enumeration
|
|
365
|
+
const deviceClass =
|
|
366
|
+
kind === 'camera'
|
|
367
|
+
? enumeration?.DeviceClass?.VideoCapture
|
|
368
|
+
: enumeration?.DeviceClass?.AudioCapture
|
|
369
|
+
|
|
370
|
+
return deviceClass === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: enumeration?.DeviceAccessInformation?.CreateFromDeviceClass(deviceClass)
|
|
373
|
+
} catch {
|
|
374
|
+
return undefined
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const checkPermission = async (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
379
|
+
const capability = appCapability(kind)
|
|
380
|
+
if (capability) {
|
|
381
|
+
try {
|
|
382
|
+
const mapped = accessStatusToPermission(capability.CheckAccess())
|
|
383
|
+
// A missing manifest declaration is a setup error, not a
|
|
384
|
+
// status — expose it as unknown here and as
|
|
385
|
+
// `configurationMissing` on request.
|
|
386
|
+
return mapped === 'notDeclared' ? 'unknown' : mapped
|
|
387
|
+
} catch {
|
|
388
|
+
// Fall through to the enumeration-based probe.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const info = deviceAccessInfo(kind)
|
|
393
|
+
if (info) {
|
|
394
|
+
try {
|
|
395
|
+
return deviceAccessStatusToPermission(info.CurrentStatus)
|
|
396
|
+
} catch {
|
|
397
|
+
return 'unknown'
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return 'unknown'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let permissionChain: Promise<unknown> = Promise.resolve()
|
|
405
|
+
const requestPermission = (kind: CameraPermissionKind): Promise<CameraPermissionStatus> => {
|
|
406
|
+
const result = permissionChain.then(async () => {
|
|
407
|
+
const status = await checkPermission(kind)
|
|
408
|
+
if (status !== 'notDetermined') {
|
|
409
|
+
if (status === 'unknown') {
|
|
410
|
+
try {
|
|
411
|
+
if (accessStatusToPermission(appCapability(kind)?.CheckAccess()) === 'notDeclared') {
|
|
412
|
+
throw error(
|
|
413
|
+
'configurationMissing',
|
|
414
|
+
'requestPermission',
|
|
415
|
+
`Missing '${capabilityNameFor(kind)}' device capability in the host app's Package.appxmanifest`,
|
|
416
|
+
{ permission: kind },
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
} catch (cause) {
|
|
420
|
+
if (cause instanceof CameraCaptureError) {
|
|
421
|
+
throw cause
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return status
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const capability = appCapability(kind)
|
|
430
|
+
if (!capability?.RequestAccessAsync) {
|
|
431
|
+
return 'unknown'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const next = accessStatusToPermission(await toPromise(capability.RequestAccessAsync()))
|
|
436
|
+
return next === 'notDeclared' ? 'unknown' : next
|
|
437
|
+
} catch (cause) {
|
|
438
|
+
throw error(
|
|
439
|
+
'unavailable',
|
|
440
|
+
'requestPermission',
|
|
441
|
+
`The system ${kind} permission request could not run`,
|
|
442
|
+
cause,
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
permissionChain = result.catch(() => {})
|
|
448
|
+
return result
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const listDevices = async (): Promise<WindowsCameraDevice[]> => {
|
|
452
|
+
const enumeration = projection().Windows?.Devices?.Enumeration
|
|
453
|
+
const deviceClass = enumeration?.DeviceClass?.VideoCapture
|
|
454
|
+
if (!enumeration?.DeviceInformation?.FindAllAsync || deviceClass === undefined) {
|
|
455
|
+
return []
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const found = await toPromise(enumeration.DeviceInformation.FindAllAsync(deviceClass))
|
|
459
|
+
return vectorToArray(found).map((device: any) => ({
|
|
460
|
+
id: String(device?.Id ?? device?.id ?? ''),
|
|
461
|
+
label: String(device?.Name ?? device?.name ?? device?.Id ?? ''),
|
|
462
|
+
facing: panelToFacing(
|
|
463
|
+
Number(device?.EnclosureLocation?.Panel ?? device?.EnclosureLocation?.panel),
|
|
464
|
+
),
|
|
465
|
+
}))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const listAudioDevices = async (): Promise<number> => {
|
|
469
|
+
try {
|
|
470
|
+
const enumeration = projection().Windows?.Devices?.Enumeration
|
|
471
|
+
const deviceClass = enumeration?.DeviceClass?.AudioCapture
|
|
472
|
+
if (!enumeration?.DeviceInformation?.FindAllAsync || deviceClass === undefined) {
|
|
473
|
+
return 0
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const found = await toPromise(enumeration.DeviceInformation.FindAllAsync(deviceClass))
|
|
477
|
+
return Number(found?.Size ?? found?.Count ?? 0)
|
|
478
|
+
} catch {
|
|
479
|
+
return 0
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const defaultDeviceId = (): string | undefined => {
|
|
484
|
+
try {
|
|
485
|
+
const devices = projection().Windows?.Media?.Devices
|
|
486
|
+
const kind = devices?.AudioVideoCaptureDeviceKind?.Video
|
|
487
|
+
const id = devices?.MediaDevice?.GetDefaultVideoCaptureId?.(kind)
|
|
488
|
+
return typeof id === 'string' && id ? id : undefined
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const streamProfiles = (target: any, streamKind: any): WindowsStreamProfile[] => {
|
|
495
|
+
const controller = target?.VideoDeviceController
|
|
496
|
+
if (!controller?.GetAvailableMediaStreamProperties || streamKind === undefined) {
|
|
497
|
+
return []
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
return vectorToArray(controller.GetAvailableMediaStreamProperties(streamKind))
|
|
502
|
+
.map((entry: any) => ({
|
|
503
|
+
width: Number(entry?.Width ?? 0),
|
|
504
|
+
height: Number(entry?.Height ?? 0),
|
|
505
|
+
frameRate:
|
|
506
|
+
Number(entry?.FrameRate?.Numerator ?? 0) /
|
|
507
|
+
Math.max(1, Number(entry?.FrameRate?.Denominator ?? 1)),
|
|
508
|
+
subtype: typeof entry?.Subtype === 'string' ? entry.Subtype : undefined,
|
|
509
|
+
}))
|
|
510
|
+
.filter((entry: WindowsStreamProfile) => entry.width > 0 && entry.height > 0)
|
|
511
|
+
} catch {
|
|
512
|
+
return []
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const recordStreamProfiles = (): WindowsStreamProfile[] =>
|
|
517
|
+
streamProfiles(capture, projection().Windows?.Media?.Capture?.MediaStreamType?.VideoRecord)
|
|
518
|
+
|
|
519
|
+
const detachCaptureDelegates = () => {
|
|
520
|
+
for (const entry of captureDelegates) {
|
|
521
|
+
try {
|
|
522
|
+
capture[entry.event] = null
|
|
523
|
+
} catch {
|
|
524
|
+
// The projection may not accept null — dropping the
|
|
525
|
+
// references still detaches our handlers.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
captureDelegates = []
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const onCaptureFailed = (_sender: any, args: any) => {
|
|
533
|
+
const message = String(args?.Message ?? args?.message ?? 'Camera capture failed')
|
|
534
|
+
markReady(false)
|
|
535
|
+
observer?.availability(false)
|
|
536
|
+
observer?.interruption('began', /in use|denied|access/i.test(message) ? 'cameraInUse' : 'unknown')
|
|
537
|
+
if (sink) {
|
|
538
|
+
// The pipeline died under a live take — try to finalize the
|
|
539
|
+
// partial media instead of discarding it outright.
|
|
540
|
+
sink.finishing(/device|available|found/i.test(message) ? 'cameraUnavailable' : 'captureError')
|
|
541
|
+
void stopRecording()
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const onRecordLimitationExceeded = () => {
|
|
546
|
+
// The OS-level recording limitation ended the take — finalize the
|
|
547
|
+
// usable media and keep first-cause semantics from the core.
|
|
548
|
+
sink?.finishing('interrupted')
|
|
549
|
+
void stopRecording()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const attachCaptureDelegates = (target: any) => {
|
|
553
|
+
const { NSWinRT } = projection()
|
|
554
|
+
const subscribe = (event: string, type: string, handler: (...args: any[]) => void) => {
|
|
555
|
+
try {
|
|
556
|
+
const delegate = NSWinRT?.asDelegate ? NSWinRT.asDelegate(type, handler) : handler
|
|
557
|
+
target[event] = delegate
|
|
558
|
+
captureDelegates.push({ event, delegate })
|
|
559
|
+
} catch {
|
|
560
|
+
try {
|
|
561
|
+
target[event] = handler
|
|
562
|
+
captureDelegates.push({ event, delegate: handler })
|
|
563
|
+
} catch {
|
|
564
|
+
// The event is unsubscribable on this projection — the
|
|
565
|
+
// adapter still functions without that notification.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
subscribe(
|
|
571
|
+
'Failed',
|
|
572
|
+
'Windows.Foundation.TypedEventHandler`2<Windows.Media.Capture.MediaCapture,Windows.Media.Capture.MediaCaptureFailedEventArgs>',
|
|
573
|
+
onCaptureFailed,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
subscribe(
|
|
577
|
+
'RecordLimitationExceeded',
|
|
578
|
+
'Windows.Media.Capture.RecordLimitationExceededEventHandler',
|
|
579
|
+
onRecordLimitationExceeded,
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const stopWatcher = () => {
|
|
584
|
+
if (!watcher) {
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const entry of watcherDelegates) {
|
|
589
|
+
try {
|
|
590
|
+
watcher[entry.event] = null
|
|
591
|
+
} catch {
|
|
592
|
+
// See detachCaptureDelegates.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
watcherDelegates = []
|
|
597
|
+
try {
|
|
598
|
+
watcher.Stop()
|
|
599
|
+
} catch {
|
|
600
|
+
// A stopped or failed watcher still releases cleanly.
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
watcher = undefined
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const ensureWatcher = () => {
|
|
607
|
+
if (watcher) {
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const enumeration = projection().Windows?.Devices?.Enumeration
|
|
612
|
+
const deviceClass = enumeration?.DeviceClass?.VideoCapture
|
|
613
|
+
if (!enumeration?.DeviceInformation?.CreateWatcher || deviceClass === undefined) {
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const target = enumeration.DeviceInformation.CreateWatcher(deviceClass)
|
|
619
|
+
const { NSWinRT } = projection()
|
|
620
|
+
const subscribe = (event: string, type: string, handler: (...args: any[]) => void) => {
|
|
621
|
+
const delegate = NSWinRT?.asDelegate ? NSWinRT.asDelegate(type, handler) : handler
|
|
622
|
+
target[event] = delegate
|
|
623
|
+
watcherDelegates.push({ event, delegate })
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
subscribe(
|
|
627
|
+
'Removed',
|
|
628
|
+
'Windows.Foundation.TypedEventHandler`2<Windows.Devices.Enumeration.DeviceWatcher,Windows.Devices.Enumeration.DeviceInformationUpdate>',
|
|
629
|
+
(_sender: any, update: any) => {
|
|
630
|
+
const id = String(update?.Id ?? update?.id ?? '')
|
|
631
|
+
if (!id || id === captureDevice?.id) {
|
|
632
|
+
observer?.availability(false)
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
subscribe(
|
|
638
|
+
'Added',
|
|
639
|
+
'Windows.Foundation.TypedEventHandler`2<Windows.Devices.Enumeration.DeviceWatcher,Windows.Devices.Enumeration.DeviceInformation>',
|
|
640
|
+
() => observer?.availability(true),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
target.Start()
|
|
644
|
+
watcher = target
|
|
645
|
+
} catch {
|
|
646
|
+
watcher = undefined
|
|
647
|
+
watcherDelegates = []
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const applyStreamProperties = async (target: any) => {
|
|
652
|
+
const streamType = projection().Windows?.Media?.Capture?.MediaStreamType
|
|
653
|
+
const controller = target?.VideoDeviceController
|
|
654
|
+
const candidates = streamProfiles(target, streamType?.VideoRecord)
|
|
655
|
+
const chosen = chooseStandardProfile(candidates)
|
|
656
|
+
if (!controller?.SetMediaStreamPropertiesAsync || !chosen || !streamType) {
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const props = candidates.length
|
|
661
|
+
? vectorToArray(controller.GetAvailableMediaStreamProperties(streamType.VideoRecord)).find(
|
|
662
|
+
(entry: any) =>
|
|
663
|
+
Number(entry?.Width) === chosen.width && Number(entry?.Height) === chosen.height,
|
|
664
|
+
)
|
|
665
|
+
: undefined
|
|
666
|
+
|
|
667
|
+
if (!props) {
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await toPromise(controller.SetMediaStreamPropertiesAsync(streamType.VideoRecord, props))
|
|
673
|
+
try {
|
|
674
|
+
await toPromise(controller.SetMediaStreamPropertiesAsync(streamType.VideoPreview, props))
|
|
675
|
+
} catch {
|
|
676
|
+
// A preview format mismatch must not block recording.
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
// The driver rejected the format request — the device default
|
|
680
|
+
// still records and capabilities disclose what was measured.
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const releaseClaim = () => {
|
|
685
|
+
if (captureDevice && claimedDevices.get(captureDevice.id) === lease) {
|
|
686
|
+
claimedDevices.delete(captureDevice.id)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const closeCapture = () => {
|
|
691
|
+
detachCaptureDelegates()
|
|
692
|
+
releaseClaim()
|
|
693
|
+
const target = capture
|
|
694
|
+
capture = undefined
|
|
695
|
+
captureDevice = undefined
|
|
696
|
+
captureAudioMode = false
|
|
697
|
+
if (!target) {
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
target.Close()
|
|
703
|
+
} catch {
|
|
704
|
+
try {
|
|
705
|
+
target.Dispose()
|
|
706
|
+
} catch {
|
|
707
|
+
// GC reclaims the device handle.
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const ensureCapture = async (): Promise<any> => {
|
|
713
|
+
const epoch = generation
|
|
714
|
+
if (capture) {
|
|
715
|
+
return capture
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const { Windows } = projection()
|
|
719
|
+
const devices = await listDevices()
|
|
720
|
+
const selected = selectCameraDevice(currentConfig.camera, devices, defaultDeviceId())
|
|
721
|
+
if (!selected) {
|
|
722
|
+
throw error('unavailable', 'startPreview', 'No camera device is available')
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const owner = claimedDevices.get(selected.id)
|
|
726
|
+
if (owner && owner !== lease) {
|
|
727
|
+
throw error('busy', 'startPreview', 'Another camera session still owns this device')
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const settings = new Windows.Media.Capture.MediaCaptureInitializationSettings()
|
|
731
|
+
settings.VideoDeviceId = selected.id
|
|
732
|
+
settings.StreamingCaptureMode =
|
|
733
|
+
currentConfig.audio === true ? captureModeAudioAndVideo : captureModeVideo
|
|
734
|
+
|
|
735
|
+
const target = new Windows.Media.Capture.MediaCapture()
|
|
736
|
+
try {
|
|
737
|
+
await toPromise(target.InitializeAsync(settings))
|
|
738
|
+
} catch (cause) {
|
|
739
|
+
try {
|
|
740
|
+
target.Close()
|
|
741
|
+
} catch {
|
|
742
|
+
// See closeCapture.
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
throw error(
|
|
746
|
+
captureFailureKind(cause),
|
|
747
|
+
'startPreview',
|
|
748
|
+
'The camera could not be initialized',
|
|
749
|
+
cause,
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (epoch !== generation || released) {
|
|
754
|
+
try {
|
|
755
|
+
target.Close()
|
|
756
|
+
} catch {
|
|
757
|
+
// See closeCapture.
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
throw error('unavailable', 'startPreview', 'The camera session was released')
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
capture = target
|
|
764
|
+
captureDevice = selected
|
|
765
|
+
captureAudioMode = currentConfig.audio === true
|
|
766
|
+
claimedDevices.set(selected.id, lease)
|
|
767
|
+
attachCaptureDelegates(target)
|
|
768
|
+
ensureWatcher()
|
|
769
|
+
await applyStreamProperties(target)
|
|
770
|
+
try {
|
|
771
|
+
if (selected.facing === 'front' && typeof target.SetPreviewMirroring === 'function') {
|
|
772
|
+
target.SetPreviewMirroring(true)
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
// Mirrored preview is cosmetic; recorded output stays unmirrored.
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return target
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const activate = async (): Promise<void> => {
|
|
782
|
+
const epoch = ++generation
|
|
783
|
+
try {
|
|
784
|
+
const target = await ensureCapture()
|
|
785
|
+
if (!captureElement || epoch !== generation) {
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
captureElement.Source = target
|
|
791
|
+
} catch (cause) {
|
|
792
|
+
throw error('unavailable', 'startPreview', 'The preview element rejected the source', cause)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
await toPromise(target.StartPreviewAsync())
|
|
796
|
+
if (epoch !== generation) {
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
markReady(true)
|
|
801
|
+
listener?.onReady?.()
|
|
802
|
+
} catch (cause) {
|
|
803
|
+
if (epoch === generation) {
|
|
804
|
+
markReady(false)
|
|
805
|
+
listener?.onError?.(
|
|
806
|
+
cause instanceof CameraCaptureError
|
|
807
|
+
? cause
|
|
808
|
+
: error('unavailable', 'startPreview', 'Camera preview could not start', cause),
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const deactivate = async () => {
|
|
815
|
+
generation += 1
|
|
816
|
+
markReady(false)
|
|
817
|
+
const target = capture
|
|
818
|
+
if (captureElement) {
|
|
819
|
+
try {
|
|
820
|
+
captureElement.Source = null
|
|
821
|
+
} catch {
|
|
822
|
+
// Detach is best-effort — the element may already be gone.
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (target && !sink) {
|
|
827
|
+
try {
|
|
828
|
+
await toPromise(target.StopPreviewAsync())
|
|
829
|
+
} catch {
|
|
830
|
+
// The pipeline may already be down.
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const recordingsFolder = async (): Promise<any> => {
|
|
836
|
+
const storage = projection().Windows?.Storage
|
|
837
|
+
const folder = storage?.ApplicationData?.Current?.LocalFolder
|
|
838
|
+
if (!folder?.CreateFolderAsync) {
|
|
839
|
+
throw error('destinationUnavailable', 'startRecording', 'App-private storage is unavailable')
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return toPromise(folder.CreateFolderAsync('octane-camera', collisionOpenIfExists))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const reserveDestination = async (destinationPath: string | undefined): Promise<any> => {
|
|
846
|
+
const storage = projection().Windows?.Storage
|
|
847
|
+
if (!destinationPath) {
|
|
848
|
+
const folder = await recordingsFolder()
|
|
849
|
+
const name = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`
|
|
850
|
+
return toPromise(folder.CreateFileAsync(name, collisionGenerateUniqueName))
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const filePath = normalizePath(destinationPath)
|
|
854
|
+
const directory = filePath.slice(0, filePath.lastIndexOf('\\'))
|
|
855
|
+
const name = filePath.slice(filePath.lastIndexOf('\\') + 1)
|
|
856
|
+
if (!directory || !name) {
|
|
857
|
+
throw error('destinationUnavailable', 'startRecording', 'destinationFileUrl is not a file path')
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const tempPath = String(storage?.ApplicationData?.Current?.TemporaryFolder?.Path ?? '')
|
|
861
|
+
if (tempPath && filePath.toLowerCase().startsWith(tempPath.toLowerCase())) {
|
|
862
|
+
throw error(
|
|
863
|
+
'destinationUnavailable',
|
|
864
|
+
'startRecording',
|
|
865
|
+
'Temporary storage does not satisfy durable output',
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const folder = await toPromise(storage.StorageFolder.GetFolderFromPathAsync(directory)).catch(
|
|
870
|
+
(cause: unknown) => {
|
|
871
|
+
throw error(
|
|
872
|
+
'destinationUnavailable',
|
|
873
|
+
'startRecording',
|
|
874
|
+
'destinationFileUrl directory is not writable',
|
|
875
|
+
cause,
|
|
876
|
+
)
|
|
877
|
+
},
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
return toPromise(folder.CreateFileAsync(name, collisionFailIfExists)).catch(
|
|
881
|
+
(cause: unknown) => {
|
|
882
|
+
throw error(
|
|
883
|
+
'destinationUnavailable',
|
|
884
|
+
'startRecording',
|
|
885
|
+
'destinationFileUrl already exists or cannot be created',
|
|
886
|
+
cause,
|
|
887
|
+
)
|
|
888
|
+
},
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const freeDiskBytes = async (): Promise<number> => {
|
|
893
|
+
try {
|
|
894
|
+
const folder = projection().Windows?.Storage?.ApplicationData?.Current?.LocalFolder
|
|
895
|
+
const result = await toPromise(
|
|
896
|
+
folder.Properties.RetrievePropertiesAsync(['System.FreeSpace']),
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
const value = result?.['System.FreeSpace'] ?? result?.Lookup?.('System.FreeSpace')
|
|
900
|
+
return Number.isFinite(Number(value)) ? Number(value) : Number.MAX_SAFE_INTEGER
|
|
901
|
+
} catch {
|
|
902
|
+
return Number.MAX_SAFE_INTEGER
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const removeFileQuietly = async (file: any) => {
|
|
907
|
+
if (!file) {
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
await toPromise(file.DeleteAsync())
|
|
913
|
+
} catch {
|
|
914
|
+
// Temporary leftovers are reclaimed by the OS.
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const nativeVideoMetadata = async (file: any, audioRequested: boolean) => {
|
|
919
|
+
const { Windows } = projection()
|
|
920
|
+
const basic = await toPromise(file.GetBasicPropertiesAsync())
|
|
921
|
+
const size = Number(basic?.Size ?? 0)
|
|
922
|
+
if (!(size > 0)) {
|
|
923
|
+
throw error('noMedia', 'finalize', 'The recorder produced an empty movie file')
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const video = await toPromise(file.Properties.GetVideoPropertiesAsync())
|
|
927
|
+
const width = Number(video?.Width ?? 0)
|
|
928
|
+
const height = Number(video?.Height ?? 0)
|
|
929
|
+
let durationMs = timeSpanToMs(video?.Duration)
|
|
930
|
+
let hasAudio = false
|
|
931
|
+
let audioKnown = true
|
|
932
|
+
try {
|
|
933
|
+
const source = Windows.Media.Core.MediaSource.CreateFromStorageFile(file)
|
|
934
|
+
const item = new Windows.Media.Playback.MediaPlaybackItem(source)
|
|
935
|
+
const audioTracks = item?.AudioTracks
|
|
936
|
+
hasAudio = Number(audioTracks?.Size ?? audioTracks?.Count ?? 0) > 0
|
|
937
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
938
|
+
durationMs = timeSpanToMs(source?.Duration)
|
|
939
|
+
}
|
|
940
|
+
} catch {
|
|
941
|
+
// The audio-track probe is mandatory for requested audio — the
|
|
942
|
+
// byte-level reader must prove the track instead of guessing.
|
|
943
|
+
audioKnown = false
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (audioRequested && !audioKnown) {
|
|
947
|
+
throw error(
|
|
948
|
+
'finalizationFailed',
|
|
949
|
+
'finalize',
|
|
950
|
+
'The audio track could not be verified in the finalized movie',
|
|
951
|
+
)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return {
|
|
955
|
+
durationMs,
|
|
956
|
+
width,
|
|
957
|
+
height,
|
|
958
|
+
hasAudio: audioKnown && hasAudio,
|
|
959
|
+
orientation: videoOrientationToOrientation(Number(video?.Orientation)),
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const mediabunnyMetadata = async (file: any) => {
|
|
964
|
+
const { Windows } = projection()
|
|
965
|
+
const buffer = await toPromise(Windows.Storage.FileIO.ReadBufferAsync(file))
|
|
966
|
+
const length = Number(buffer?.Length ?? 0)
|
|
967
|
+
if (!(length > 0)) {
|
|
968
|
+
throw error('noMedia', 'finalize', 'The recorder produced an empty movie file')
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const reader = Windows.Storage.Streams.DataReader.FromBuffer(buffer)
|
|
972
|
+
const bytes = new Uint8Array(length)
|
|
973
|
+
reader.ReadBytes(bytes)
|
|
974
|
+
const { BufferSource, BlobSource, Input, MP4 } = await import('mediabunny')
|
|
975
|
+
const Source = BufferSource ?? BlobSource
|
|
976
|
+
const input = new Input({ source: new Source(bytes as any), formats: [MP4] })
|
|
977
|
+
try {
|
|
978
|
+
const video = await input.getPrimaryVideoTrack()
|
|
979
|
+
if (!video) {
|
|
980
|
+
throw error('noMedia', 'finalize', 'The movie contains no video track')
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const audio = await input.getPrimaryAudioTrack()
|
|
984
|
+
const hasAudio = audio !== null && (await audio.computeDuration()) > 0
|
|
985
|
+
const durationMs = (await input.computeDuration()) * 1000
|
|
986
|
+
const width = await video.getDisplayWidth()
|
|
987
|
+
const height = await video.getDisplayHeight()
|
|
988
|
+
return { durationMs, width, height, hasAudio, orientation: 'unspecified' as CameraOrientation }
|
|
989
|
+
} finally {
|
|
990
|
+
input.dispose()
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const teardown = () => {
|
|
995
|
+
if (!host) {
|
|
996
|
+
void deactivate().then(() => {
|
|
997
|
+
if (!host && !sink) {
|
|
998
|
+
closeCapture()
|
|
999
|
+
}
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const finishWithError = (
|
|
1005
|
+
stage: 'preparation' | 'capture' | 'finalization' | 'storage',
|
|
1006
|
+
cause: CaptureIssue,
|
|
1007
|
+
) => {
|
|
1008
|
+
const target = sink
|
|
1009
|
+
sink = undefined
|
|
1010
|
+
request = undefined
|
|
1011
|
+
finalizing = false
|
|
1012
|
+
wantStop = false
|
|
1013
|
+
recordStartedAt = 0
|
|
1014
|
+
const file = recordFile
|
|
1015
|
+
recordFile = undefined
|
|
1016
|
+
void removeFileQuietly(file)
|
|
1017
|
+
target?.settled({ kind: 'failed', stage, issue: cause })
|
|
1018
|
+
teardown()
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const settleClip = async (file: any) => {
|
|
1022
|
+
const target = sink
|
|
1023
|
+
const take = request
|
|
1024
|
+
if (!target || !take) {
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
try {
|
|
1029
|
+
let metadata: Awaited<ReturnType<typeof nativeVideoMetadata>>
|
|
1030
|
+
try {
|
|
1031
|
+
metadata = await nativeVideoMetadata(file, take.audio)
|
|
1032
|
+
} catch (nativeCause) {
|
|
1033
|
+
if (nativeCause instanceof CameraCaptureError && nativeCause.kind === 'noMedia') {
|
|
1034
|
+
throw nativeCause
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
metadata = await mediabunnyMetadata(file)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (!(metadata.durationMs > 0) || !(metadata.width > 0) || !(metadata.height > 0)) {
|
|
1041
|
+
throw error('noMedia', 'finalize', 'The finalized movie has no usable video media')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (take.audio && !metadata.hasAudio) {
|
|
1045
|
+
throw error(
|
|
1046
|
+
'captureFailed',
|
|
1047
|
+
'finalize',
|
|
1048
|
+
'Requested audio is absent from the finalized movie',
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const filePath = String(file?.Path ?? '')
|
|
1053
|
+
sink = undefined
|
|
1054
|
+
request = undefined
|
|
1055
|
+
recordFile = undefined
|
|
1056
|
+
finalizing = false
|
|
1057
|
+
wantStop = false
|
|
1058
|
+
target.settled({
|
|
1059
|
+
kind: 'clip',
|
|
1060
|
+
output: {
|
|
1061
|
+
kind: 'nativeFile',
|
|
1062
|
+
resourceId: `file:${filePath}`,
|
|
1063
|
+
fileUrl: fileUrlForPath(filePath),
|
|
1064
|
+
},
|
|
1065
|
+
mimeType: 'video/mp4',
|
|
1066
|
+
durationMs: Math.round(metadata.durationMs),
|
|
1067
|
+
width: metadata.width,
|
|
1068
|
+
height: metadata.height,
|
|
1069
|
+
orientation:
|
|
1070
|
+
take.orientation && take.orientation !== 'unspecified'
|
|
1071
|
+
? take.orientation
|
|
1072
|
+
: metadata.orientation,
|
|
1073
|
+
hasAudio: take.audio && metadata.hasAudio,
|
|
1074
|
+
})
|
|
1075
|
+
} catch (cause) {
|
|
1076
|
+
sink = undefined
|
|
1077
|
+
request = undefined
|
|
1078
|
+
recordFile = undefined
|
|
1079
|
+
finalizing = false
|
|
1080
|
+
wantStop = false
|
|
1081
|
+
void removeFileQuietly(file)
|
|
1082
|
+
target.settled({
|
|
1083
|
+
kind: 'failed',
|
|
1084
|
+
stage: 'finalization',
|
|
1085
|
+
issue:
|
|
1086
|
+
cause instanceof CameraCaptureError
|
|
1087
|
+
? cause
|
|
1088
|
+
: issue(
|
|
1089
|
+
'finalizationFailed',
|
|
1090
|
+
'finalize',
|
|
1091
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
1092
|
+
{ cause },
|
|
1093
|
+
),
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
recordStartedAt = 0
|
|
1098
|
+
teardown()
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const stopRecording = async () => {
|
|
1102
|
+
const target = capture
|
|
1103
|
+
const file = recordFile
|
|
1104
|
+
// recordStartedAt stays 0 while StartRecordToStorageFileAsync is in
|
|
1105
|
+
// flight — an early stop is honored by `wantStop` once it resolves.
|
|
1106
|
+
if (!target || !file || finalizing || recordStartedAt === 0) {
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
finalizing = true
|
|
1111
|
+
try {
|
|
1112
|
+
await toPromise(target.StopRecordAsync())
|
|
1113
|
+
} catch (cause) {
|
|
1114
|
+
finishWithError(
|
|
1115
|
+
'finalization',
|
|
1116
|
+
error('finalizationFailed', 'finalize', 'The movie could not be finalized', cause),
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
await settleClip(file)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const onSuspend = () => {
|
|
1126
|
+
interrupted = true
|
|
1127
|
+
markReady(false)
|
|
1128
|
+
observer?.interruption('began', 'background')
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const onResume = () => {
|
|
1132
|
+
interrupted = false
|
|
1133
|
+
observer?.interruption('ended', 'background')
|
|
1134
|
+
if (capture) {
|
|
1135
|
+
observer?.availability(true)
|
|
1136
|
+
markReady(!!captureElement)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
let lifecycleBound = false
|
|
1141
|
+
const bindLifecycle = () => {
|
|
1142
|
+
if (lifecycleBound) {
|
|
1143
|
+
return
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
lifecycleBound = true
|
|
1147
|
+
try {
|
|
1148
|
+
Application.on(Application.suspendEvent, onSuspend)
|
|
1149
|
+
Application.on(Application.resumeEvent, onResume)
|
|
1150
|
+
} catch {
|
|
1151
|
+
// The windows core may not emit suspend/resume — capture
|
|
1152
|
+
// still works without lifecycle notifications.
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const unbindLifecycle = () => {
|
|
1157
|
+
if (!lifecycleBound) {
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
lifecycleBound = false
|
|
1162
|
+
try {
|
|
1163
|
+
Application.off(Application.suspendEvent, onSuspend)
|
|
1164
|
+
Application.off(Application.resumeEvent, onResume)
|
|
1165
|
+
} catch {
|
|
1166
|
+
// See bindLifecycle.
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
platform: 'windows',
|
|
1172
|
+
get supported() {
|
|
1173
|
+
return projectionAvailable()
|
|
1174
|
+
},
|
|
1175
|
+
checkPermission,
|
|
1176
|
+
requestPermission: async (kind) => {
|
|
1177
|
+
const status = await requestPermission(kind)
|
|
1178
|
+
if (kind === 'camera' && status === 'granted' && host && !sink && !released && !capture) {
|
|
1179
|
+
try {
|
|
1180
|
+
await activate()
|
|
1181
|
+
} catch {
|
|
1182
|
+
// Activation reports through the preview listener.
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return status
|
|
1187
|
+
},
|
|
1188
|
+
getCapabilities: async () => {
|
|
1189
|
+
const devices = await listDevices()
|
|
1190
|
+
const cameras: CameraDeviceInfo[] = devices.map((entry) => ({
|
|
1191
|
+
id: entry.id,
|
|
1192
|
+
label: entry.label,
|
|
1193
|
+
facing: entry.facing,
|
|
1194
|
+
}))
|
|
1195
|
+
|
|
1196
|
+
const supported = projectionAvailable() && cameras.length > 0
|
|
1197
|
+
const cameraStatus = await checkPermission('camera')
|
|
1198
|
+
const available = supported && cameraStatus === 'granted'
|
|
1199
|
+
const measured = capture ? chooseStandardProfile(recordStreamProfiles()) : undefined
|
|
1200
|
+
const profile = measured ?? { width: 1280, height: 720, frameRate: 30 }
|
|
1201
|
+
return {
|
|
1202
|
+
supported,
|
|
1203
|
+
reason: !projectionAvailable()
|
|
1204
|
+
? 'The WinUI camera runtime is unavailable'
|
|
1205
|
+
: cameras.length === 0
|
|
1206
|
+
? 'No camera device is available on this host'
|
|
1207
|
+
: cameraStatus !== 'granted'
|
|
1208
|
+
? 'Camera permission has not been granted'
|
|
1209
|
+
: undefined,
|
|
1210
|
+
available,
|
|
1211
|
+
cameras,
|
|
1212
|
+
profiles: [{ profile: 'standard', ...profile }],
|
|
1213
|
+
audio: (await listAudioDevices()) > 0,
|
|
1214
|
+
orientations:
|
|
1215
|
+
capture && typeof capture.SetRecordRotationAsync === 'function'
|
|
1216
|
+
? ['portrait', 'portraitUpsideDown', 'landscapeLeft', 'landscapeRight']
|
|
1217
|
+
: [],
|
|
1218
|
+
durationLimit: 'bestEffort',
|
|
1219
|
+
elapsedTime: 'estimated',
|
|
1220
|
+
output: {
|
|
1221
|
+
mimeType: 'video/mp4',
|
|
1222
|
+
container: 'mp4',
|
|
1223
|
+
storage: 'appPrivateFile',
|
|
1224
|
+
destinationFileUrl: true,
|
|
1225
|
+
},
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
previewReady: () => previewReadyFlag && !interrupted,
|
|
1229
|
+
validateStart: (options) => {
|
|
1230
|
+
if (
|
|
1231
|
+
options.orientation &&
|
|
1232
|
+
options.orientation !== 'unspecified' &&
|
|
1233
|
+
(!capture || typeof capture.SetRecordRotationAsync !== 'function')
|
|
1234
|
+
) {
|
|
1235
|
+
throw error(
|
|
1236
|
+
'unsupportedConfiguration',
|
|
1237
|
+
'startRecording',
|
|
1238
|
+
'Fixed cardinal orientation has not been qualified on this Windows source',
|
|
1239
|
+
)
|
|
1240
|
+
}
|
|
1241
|
+
},
|
|
1242
|
+
attachPreview: (target, callbacks) => {
|
|
1243
|
+
if (released || sink) {
|
|
1244
|
+
throw error('invalidState', 'attachPreview', 'The camera is disposed or finalizing')
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
host = target
|
|
1248
|
+
listener = callbacks
|
|
1249
|
+
const view = target as any
|
|
1250
|
+
captureElement = view?.nativeViewProtected ?? view
|
|
1251
|
+
bindLifecycle()
|
|
1252
|
+
void checkPermission('camera').then((status) => {
|
|
1253
|
+
if (released || !host) {
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (status !== 'granted') {
|
|
1258
|
+
listener?.onError?.(
|
|
1259
|
+
error(
|
|
1260
|
+
status === 'restricted' ? 'permissionRestricted' : 'permissionDenied',
|
|
1261
|
+
'preview',
|
|
1262
|
+
'Request camera permission before activating this preview',
|
|
1263
|
+
{ permission: 'camera' },
|
|
1264
|
+
),
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
void activate()
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
return () => {
|
|
1274
|
+
host = undefined
|
|
1275
|
+
listener = undefined
|
|
1276
|
+
captureElement = undefined
|
|
1277
|
+
void deactivate().then(() => {
|
|
1278
|
+
if (!sink) {
|
|
1279
|
+
closeCapture()
|
|
1280
|
+
}
|
|
1281
|
+
})
|
|
1282
|
+
}
|
|
1283
|
+
},
|
|
1284
|
+
configure: (next) => {
|
|
1285
|
+
const changed =
|
|
1286
|
+
JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
|
|
1287
|
+
(next.audio ?? false) !== (currentConfig.audio ?? false)
|
|
1288
|
+
|
|
1289
|
+
currentConfig = { ...next }
|
|
1290
|
+
if (!changed || !capture) {
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// A camera or audio-mode change cannot mutate a live MediaCapture —
|
|
1295
|
+
// rebuild it and restore the attached preview.
|
|
1296
|
+
const hadPreview = !!captureElement
|
|
1297
|
+
void deactivate()
|
|
1298
|
+
.then(() => {
|
|
1299
|
+
closeCapture()
|
|
1300
|
+
if (hadPreview && host) {
|
|
1301
|
+
return activate()
|
|
1302
|
+
}
|
|
1303
|
+
})
|
|
1304
|
+
.catch(() => {
|
|
1305
|
+
observer?.availability(false)
|
|
1306
|
+
})
|
|
1307
|
+
},
|
|
1308
|
+
startCapture: (take, target) => {
|
|
1309
|
+
if (!capture || !captureDevice || !previewReadyFlag || interrupted) {
|
|
1310
|
+
target.settled({
|
|
1311
|
+
kind: 'failed',
|
|
1312
|
+
stage: 'preparation',
|
|
1313
|
+
issue: issue('unavailable', 'startRecording', 'The camera preview is not running'),
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
sink = target
|
|
1320
|
+
request = take
|
|
1321
|
+
const active = capture
|
|
1322
|
+
const run = async () => {
|
|
1323
|
+
if (take.audio) {
|
|
1324
|
+
const micStatus = await checkPermission('microphone')
|
|
1325
|
+
if (micStatus !== 'granted') {
|
|
1326
|
+
throw error(
|
|
1327
|
+
micStatus === 'restricted'
|
|
1328
|
+
? 'permissionRestricted'
|
|
1329
|
+
: micStatus === 'unavailable'
|
|
1330
|
+
? 'unavailable'
|
|
1331
|
+
: 'permissionDenied',
|
|
1332
|
+
'startRecording',
|
|
1333
|
+
`Microphone permission is ${micStatus}`,
|
|
1334
|
+
{ permission: 'microphone' },
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if (!captureAudioMode) {
|
|
1339
|
+
throw error(
|
|
1340
|
+
'unsupportedConfiguration',
|
|
1341
|
+
'startRecording',
|
|
1342
|
+
'This session was created without audio; configure audio before recording',
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if ((await freeDiskBytes()) < minFreeDiskBytes) {
|
|
1348
|
+
throw error(
|
|
1349
|
+
'insufficientStorage',
|
|
1350
|
+
'startRecording',
|
|
1351
|
+
'Not enough local storage for a movie recording',
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (take.orientation) {
|
|
1356
|
+
const rotation = orientationToRotation(take.orientation)
|
|
1357
|
+
if (rotation === undefined || typeof active.SetRecordRotationAsync !== 'function') {
|
|
1358
|
+
throw error(
|
|
1359
|
+
'unsupportedConfiguration',
|
|
1360
|
+
'startRecording',
|
|
1361
|
+
`Orientation '${take.orientation}' is not supported by this camera`,
|
|
1362
|
+
)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
try {
|
|
1366
|
+
await toPromise(active.SetRecordRotationAsync(rotation))
|
|
1367
|
+
} catch (cause) {
|
|
1368
|
+
throw error(
|
|
1369
|
+
'unsupportedConfiguration',
|
|
1370
|
+
'startRecording',
|
|
1371
|
+
`Orientation '${take.orientation}' was rejected by this camera`,
|
|
1372
|
+
cause,
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const file = await reserveDestination(take.destinationPath)
|
|
1378
|
+
const media = projection().Windows?.Media
|
|
1379
|
+
const profile = media?.MediaProperties?.MediaEncodingProfile?.CreateMp4(
|
|
1380
|
+
media?.MediaProperties?.VideoEncodingQuality?.Auto ?? 0,
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
if (!profile) {
|
|
1384
|
+
await removeFileQuietly(file)
|
|
1385
|
+
throw error(
|
|
1386
|
+
'unsupportedPlatform',
|
|
1387
|
+
'startRecording',
|
|
1388
|
+
'MP4 encoding profiles are unavailable on this host',
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const chosen = chooseStandardProfile(recordStreamProfiles())
|
|
1393
|
+
try {
|
|
1394
|
+
if (chosen && profile.Video) {
|
|
1395
|
+
profile.Video.Width = chosen.width
|
|
1396
|
+
profile.Video.Height = chosen.height
|
|
1397
|
+
if (profile.Video.FrameRate) {
|
|
1398
|
+
profile.Video.FrameRate.Numerator = Math.max(1, Math.round(chosen.frameRate * 1000))
|
|
1399
|
+
profile.Video.FrameRate.Denominator = 1000
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} catch {
|
|
1403
|
+
// The auto profile still describes a real encoding.
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (!take.audio) {
|
|
1407
|
+
try {
|
|
1408
|
+
profile.Audio = null
|
|
1409
|
+
} catch {
|
|
1410
|
+
// A projection without property writes still records;
|
|
1411
|
+
// silence is verified from the finalized movie.
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
recordFile = file
|
|
1416
|
+
wantStop = false
|
|
1417
|
+
try {
|
|
1418
|
+
await toPromise(active.StartRecordToStorageFileAsync(profile, file))
|
|
1419
|
+
} catch (cause) {
|
|
1420
|
+
recordFile = undefined
|
|
1421
|
+
await removeFileQuietly(file)
|
|
1422
|
+
throw error(
|
|
1423
|
+
captureFailureKind(cause),
|
|
1424
|
+
'startRecording',
|
|
1425
|
+
'MediaCapture could not begin recording',
|
|
1426
|
+
cause,
|
|
1427
|
+
)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
recordStartedAt = Date.now()
|
|
1431
|
+
const stream = chooseStandardProfile(recordStreamProfiles())
|
|
1432
|
+
const configuration: EffectiveCaptureConfig = {
|
|
1433
|
+
cameraId: captureDevice!.id,
|
|
1434
|
+
facing: captureDevice!.facing,
|
|
1435
|
+
audio: take.audio && captureAudioMode,
|
|
1436
|
+
width: stream?.width ?? chosen?.width ?? 0,
|
|
1437
|
+
height: stream?.height ?? chosen?.height ?? 0,
|
|
1438
|
+
frameRate: stream?.frameRate ?? chosen?.frameRate ?? 0,
|
|
1439
|
+
orientation: take.orientation ?? 'unspecified',
|
|
1440
|
+
mimeType: 'video/mp4',
|
|
1441
|
+
container: 'mp4',
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
target.started(configuration)
|
|
1445
|
+
if (wantStop) {
|
|
1446
|
+
void stopRecording()
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
run().catch((cause: unknown) => {
|
|
1451
|
+
finishWithError(
|
|
1452
|
+
'preparation',
|
|
1453
|
+
cause instanceof CameraCaptureError
|
|
1454
|
+
? cause
|
|
1455
|
+
: issue(
|
|
1456
|
+
'captureFailed',
|
|
1457
|
+
'startRecording',
|
|
1458
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
1459
|
+
{ cause },
|
|
1460
|
+
),
|
|
1461
|
+
)
|
|
1462
|
+
})
|
|
1463
|
+
},
|
|
1464
|
+
stopCapture: () => {
|
|
1465
|
+
wantStop = true
|
|
1466
|
+
void stopRecording()
|
|
1467
|
+
},
|
|
1468
|
+
elapsedMs: () => (recordStartedAt > 0 ? Math.max(0, Date.now() - recordStartedAt) : 0),
|
|
1469
|
+
openOutput: async (output) => {
|
|
1470
|
+
if (output.kind !== 'nativeFile' || output.synthetic) {
|
|
1471
|
+
throw error('invalidArgument', 'openOutput', 'Expected a native Windows movie reference')
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const filePath = normalizePath(output.fileUrl.slice('file://'.length))
|
|
1475
|
+
const storage = projection().Windows?.Storage
|
|
1476
|
+
try {
|
|
1477
|
+
await toPromise(storage.StorageFile.GetFileFromPathAsync(filePath))
|
|
1478
|
+
} catch (cause) {
|
|
1479
|
+
throw error('unavailable', 'openOutput', `Movie file is missing at ${output.fileUrl}`, cause)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const handle: MovieOutputHandle = {
|
|
1483
|
+
url: output.fileUrl,
|
|
1484
|
+
fileUrl: output.fileUrl,
|
|
1485
|
+
release: () => {},
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
return handle
|
|
1489
|
+
},
|
|
1490
|
+
release: async () => {
|
|
1491
|
+
released = true
|
|
1492
|
+
generation += 1
|
|
1493
|
+
unbindLifecycle()
|
|
1494
|
+
sink = undefined
|
|
1495
|
+
request = undefined
|
|
1496
|
+
wantStop = false
|
|
1497
|
+
finalizing = false
|
|
1498
|
+
recordStartedAt = 0
|
|
1499
|
+
const file = recordFile
|
|
1500
|
+
recordFile = undefined
|
|
1501
|
+
void removeFileQuietly(file)
|
|
1502
|
+
markReady(false)
|
|
1503
|
+
await deactivate()
|
|
1504
|
+
stopWatcher()
|
|
1505
|
+
closeCapture()
|
|
1506
|
+
host = undefined
|
|
1507
|
+
captureElement = undefined
|
|
1508
|
+
},
|
|
1509
|
+
onPreviewDetached: () => {
|
|
1510
|
+
// Finalization still owns the device while a settle is in
|
|
1511
|
+
// flight; the detach path already released it otherwise.
|
|
1512
|
+
},
|
|
1513
|
+
setObserver: (next) => {
|
|
1514
|
+
observer = next
|
|
1515
|
+
},
|
|
1516
|
+
}
|
|
1517
|
+
}
|