@octane-xplat/camera 0.0.0-nightly.g2e22171

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