@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.
Files changed (83) hide show
  1. package/README.md +63 -3
  2. package/dist/linux/CameraView.web.tsrx.js +337 -0
  3. package/dist/linux/host-movie.web.js +100 -0
  4. package/dist/linux/index.js +5 -0
  5. package/dist/linux/layout-child.web.js +29 -0
  6. package/dist/linux/movie-metadata.web.js +65 -0
  7. package/dist/linux/octane-client-build.json +8 -0
  8. package/dist/linux/session-backend.linux.js +479 -0
  9. package/dist/linux/session-core.js +368 -0
  10. package/dist/linux/session.js +29 -0
  11. package/dist/linux/synthetic-session.js +280 -0
  12. package/dist/linux/types.js +19 -0
  13. package/dist/native/CameraView.android.js +25 -1
  14. package/dist/native/CameraView.ios.js +24 -1
  15. package/dist/native/CameraView.windows.js +173 -0
  16. package/dist/native/camera-permission.windows.js +43 -0
  17. package/dist/native/camera-session.windows.js +78 -0
  18. package/dist/native/cx.mobile.js +3 -2
  19. package/dist/native/cx.windows.js +9 -0
  20. package/dist/native/index.js +4 -1
  21. package/dist/native/layout-child.windows.js +53 -0
  22. package/dist/native/octane-client-build.json +1 -1
  23. package/dist/native/session-backend.android.js +234 -0
  24. package/dist/native/session-backend.ios.js +682 -0
  25. package/dist/native/session-backend.js +55 -0
  26. package/dist/native/session-backend.windows.js +905 -0
  27. package/dist/native/session-core.js +368 -0
  28. package/dist/native/session.js +29 -0
  29. package/dist/native/synthetic-session.js +280 -0
  30. package/dist/native/types.js +19 -0
  31. package/dist/web/CameraView.web.tsrx.js +28 -8
  32. package/dist/web/host-movie.web.js +100 -0
  33. package/dist/web/index.js +4 -1
  34. package/dist/web/movie-metadata.web.js +65 -0
  35. package/dist/web/movie-storage.web.js +119 -0
  36. package/dist/web/octane-client-build.json +1 -1
  37. package/dist/web/session-backend.web.js +490 -0
  38. package/dist/web/session-core.js +368 -0
  39. package/dist/web/session.js +29 -0
  40. package/dist/web/synthetic-session.js +280 -0
  41. package/dist/web/types.js +19 -0
  42. package/package.json +23 -5
  43. package/platforms/android/AndroidManifest.xml +2 -0
  44. package/platforms/android/include.gradle +1 -0
  45. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
  46. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
  47. package/platforms/ios/Info.plist +2 -0
  48. package/platforms/macos/src/XplatCameraHost.swift +513 -0
  49. package/src/CameraView.android.tsrx +21 -1
  50. package/src/CameraView.ios.tsrx +28 -2
  51. package/src/CameraView.macos.tsrx +93 -0
  52. package/src/CameraView.web.tsrx +20 -2
  53. package/src/CameraView.windows.tsrx +124 -0
  54. package/src/camera-permission.windows.ts +76 -0
  55. package/src/camera-session.macos.ts +75 -0
  56. package/src/camera-session.windows.ts +123 -0
  57. package/src/cx.macos.ts +5 -0
  58. package/src/cx.mobile.ts +3 -2
  59. package/src/cx.windows.ts +6 -0
  60. package/src/host-movie.web.ts +180 -0
  61. package/src/index.ts +31 -0
  62. package/src/index.web.ts +31 -0
  63. package/src/layout-child.windows.ts +79 -0
  64. package/src/movie-metadata.web.ts +84 -0
  65. package/src/movie-storage.web.ts +164 -0
  66. package/src/props.ts +7 -1
  67. package/src/session-backend.android.ts +397 -0
  68. package/src/session-backend.ios.ts +1092 -0
  69. package/src/session-backend.linux.ts +869 -0
  70. package/src/session-backend.macos.ts +624 -0
  71. package/src/session-backend.ts +57 -0
  72. package/src/session-backend.web.ts +874 -0
  73. package/src/session-backend.windows.ts +1517 -0
  74. package/src/session-core.ts +659 -0
  75. package/src/session.ts +37 -0
  76. package/src/synthetic-session.ts +367 -0
  77. package/src/types.ts +329 -0
  78. package/types/index.d.ts +43 -0
  79. package/types/props.d.ts +7 -1
  80. package/types/session-core.d.ts +129 -0
  81. package/types/synthetic-session.d.ts +48 -0
  82. package/types/types.d.ts +263 -0
  83. 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
+ }