@octane-xplat/camera 0.14.0 → 0.15.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 (81) hide show
  1. package/README.md +26 -0
  2. package/package.json +22 -16
  3. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
  4. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
  5. package/platforms/macos/src/CameraOwnership.swift +22 -0
  6. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  7. package/platforms/macos/src/XplatCameraHost.swift +139 -13
  8. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  9. package/src/CameraView.android.tsrx +6 -2
  10. package/src/CameraView.ios.tsrx +4 -2
  11. package/src/CameraView.macos.tsrx +4 -2
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +4 -2
  14. package/src/camera-permission.mobile.ts +18 -4
  15. package/src/camera-session.windows.ts +4 -1
  16. package/src/host-movie.web.ts +141 -26
  17. package/src/index.ts +1 -0
  18. package/src/index.web.ts +1 -0
  19. package/src/preview-tap.ts +34 -0
  20. package/src/props.ts +8 -1
  21. package/src/session-backend.android.ts +42 -0
  22. package/src/session-backend.ios.ts +183 -17
  23. package/src/session-backend.linux.ts +22 -4
  24. package/src/session-backend.macos.ts +100 -17
  25. package/src/session-backend.ts +2 -0
  26. package/src/session-backend.web.ts +17 -0
  27. package/src/session-backend.windows.ts +88 -6
  28. package/src/session-core.ts +73 -9
  29. package/src/synthetic-session.ts +10 -0
  30. package/src/types.ts +28 -0
  31. package/types/props.d.ts +8 -1
  32. package/types/session-core.d.ts +11 -1
  33. package/types/synthetic-session.d.ts +4 -1
  34. package/types/types.d.ts +27 -0
  35. package/dist/linux/CameraView.web.tsrx.js +0 -337
  36. package/dist/linux/host-movie.web.js +0 -100
  37. package/dist/linux/index.js +0 -5
  38. package/dist/linux/layout-child.web.js +0 -29
  39. package/dist/linux/movie-metadata.web.js +0 -65
  40. package/dist/linux/octane-client-build.json +0 -8
  41. package/dist/linux/session-backend.linux.js +0 -479
  42. package/dist/linux/session-core.js +0 -368
  43. package/dist/linux/session.js +0 -29
  44. package/dist/linux/synthetic-session.js +0 -280
  45. package/dist/linux/types.js +0 -19
  46. package/dist/native/CameraView.android.js +0 -190
  47. package/dist/native/CameraView.ios.js +0 -154
  48. package/dist/native/CameraView.windows.js +0 -173
  49. package/dist/native/camera-permission.mobile.js +0 -45
  50. package/dist/native/camera-permission.windows.js +0 -43
  51. package/dist/native/camera-session.ios.js +0 -45
  52. package/dist/native/camera-session.windows.js +0 -78
  53. package/dist/native/cx.mobile.js +0 -9
  54. package/dist/native/cx.windows.js +0 -9
  55. package/dist/native/index.js +0 -5
  56. package/dist/native/layout-child.js +0 -53
  57. package/dist/native/layout-child.windows.js +0 -53
  58. package/dist/native/octane-client-build.json +0 -8
  59. package/dist/native/session-backend.android.js +0 -234
  60. package/dist/native/session-backend.ios.js +0 -682
  61. package/dist/native/session-backend.js +0 -55
  62. package/dist/native/session-backend.windows.js +0 -905
  63. package/dist/native/session-core.js +0 -368
  64. package/dist/native/session.js +0 -29
  65. package/dist/native/synthetic-session.js +0 -280
  66. package/dist/native/types.js +0 -19
  67. package/dist/web/CameraView.web.tsrx.js +0 -337
  68. package/dist/web/host-movie.web.js +0 -100
  69. package/dist/web/index.js +0 -5
  70. package/dist/web/layout-child.web.js +0 -29
  71. package/dist/web/movie-metadata.web.js +0 -65
  72. package/dist/web/movie-storage.web.js +0 -119
  73. package/dist/web/octane-client-build.json +0 -8
  74. package/dist/web/session-backend.web.js +0 -490
  75. package/dist/web/session-core.js +0 -368
  76. package/dist/web/session.js +0 -29
  77. package/dist/web/synthetic-session.js +0 -280
  78. package/dist/web/types.js +0 -19
  79. package/src/cx.macos.ts +0 -5
  80. package/src/cx.mobile.ts +0 -6
  81. package/src/cx.windows.ts +0 -6
@@ -1,905 +0,0 @@
1
- import { CameraCaptureError } from "./types.js";
2
- import { Application } from "@nativescript/core";
3
- //#region src/session-backend.windows.ts
4
- /** Windows adapter: one `Windows.Media.Capture.MediaCapture` shared between a
5
- * `Microsoft.UI.Xaml.Controls.CaptureElement` preview and a single
6
- * `StartRecordToStorageFileAsync` capture attempt. Everything reaches WinRT
7
- * through the runtime's dynamic projection (`Windows`/`Microsoft`/`NSWinRT`
8
- * globals), so every call site is guarded — a projection miss reports
9
- * `unsupported`/`unavailable`, never fabricated capture. */
10
- var deviceAccessUnspecified = 0;
11
- var deviceAccessAllowed = 1;
12
- var deviceAccessDeniedByUser = 2;
13
- var deviceAccessDeniedBySystem = 3;
14
- var appCapabilityDeniedBySystem = 0;
15
- var appCapabilityNotDeclaredByApp = 1;
16
- var appCapabilityDeniedByUser = 2;
17
- var appCapabilityUserPromptRequired = 3;
18
- var appCapabilityAllowed = 4;
19
- var panelFront = 1;
20
- var panelBack = 2;
21
- var captureModeVideo = 0;
22
- var captureModeAudioAndVideo = 2;
23
- var recordRotationNone = 0;
24
- var recordRotation90 = 1;
25
- var recordRotation180 = 2;
26
- var recordRotation270 = 3;
27
- var collisionGenerateUniqueName = 0;
28
- var collisionFailIfExists = 1;
29
- var collisionOpenIfExists = 2;
30
- var fileOrientationNormal = 0;
31
- var fileOrientation90 = 90;
32
- var fileOrientation180 = 180;
33
- var fileOrientation270 = 270;
34
- var capabilityNameFor = (kind) => kind === "camera" ? "webcam" : "microphone";
35
- var minFreeDiskBytes = 67108864;
36
- var projection = () => {
37
- const host = globalThis;
38
- return {
39
- Windows: host.Windows,
40
- Microsoft: host.Microsoft,
41
- NSWinRT: host.NSWinRT
42
- };
43
- };
44
- var error = (kind, operation, message, cause) => new CameraCaptureError({
45
- kind,
46
- operation,
47
- message,
48
- cause
49
- });
50
- var issue = (kind, operation, message, extra) => ({
51
- kind,
52
- operation,
53
- message,
54
- ...extra
55
- });
56
- /** AppCapabilityAccessStatus → portable permission. `notDeclared` is a
57
- * setup error, not a status — callers surface `configurationMissing`. */
58
- var accessStatusToPermission = (status) => {
59
- switch (status) {
60
- case appCapabilityAllowed: return "granted";
61
- case appCapabilityDeniedByUser: return "denied";
62
- case appCapabilityDeniedBySystem: return "restricted";
63
- case appCapabilityUserPromptRequired: return "notDetermined";
64
- case appCapabilityNotDeclaredByApp: return "notDeclared";
65
- default: return "unknown";
66
- }
67
- };
68
- /** DeviceAccessStatus → portable permission for hosts without AppCapability. */
69
- var deviceAccessStatusToPermission = (status) => {
70
- switch (status) {
71
- case deviceAccessAllowed: return "granted";
72
- case deviceAccessDeniedByUser: return "denied";
73
- case deviceAccessDeniedBySystem: return "restricted";
74
- case deviceAccessUnspecified: return "notDetermined";
75
- default: return "unknown";
76
- }
77
- };
78
- /** Enclosure panel → facing. Desktop cameras often report no panel — an
79
- * external webcam never fabricates front/back semantics. */
80
- var panelToFacing = (panel) => panel === panelFront ? "front" : panel === panelBack ? "back" : void 0;
81
- /** Stored display rotation (degrees CW) → cardinal orientation, matching the
82
- * iOS transform mapping. Missing or unrecognized values stay `unspecified`. */
83
- var videoOrientationToOrientation = (degrees) => {
84
- switch (degrees) {
85
- case fileOrientation90: return "portrait";
86
- case fileOrientation180: return "landscapeLeft";
87
- case fileOrientation270: return "portraitUpsideDown";
88
- case fileOrientationNormal: return "landscapeRight";
89
- default: return "unspecified";
90
- }
91
- };
92
- var orientationToRotation = (orientation) => orientation === "portrait" ? recordRotation90 : orientation === "portraitUpsideDown" ? recordRotation270 : orientation === "landscapeLeft" ? recordRotation180 : orientation === "landscapeRight" ? recordRotationNone : void 0;
93
- /** Camera selection: explicit deviceId wins; front/back match the enclosure
94
- * panel; `default` takes the OS-preferred camera then the first device. */
95
- var selectCameraDevice = (camera, devices, defaultId) => {
96
- if (camera && typeof camera === "object" && "deviceId" in camera) return devices.find((entry) => entry.id === camera.deviceId);
97
- if (camera === "front" || camera === "back") return devices.find((entry) => entry.facing === camera);
98
- return devices.find((entry) => entry.id === defaultId) ?? devices.find((entry) => entry.facing === "back") ?? devices[0];
99
- };
100
- /** Pick the standard profile: 1280x720 @ 30 fps when the source offers it,
101
- * otherwise the smallest stream then lowest frame rate. The selected
102
- * combination is disclosed — never an idealized 720p claim. */
103
- var chooseStandardProfile = (profiles) => {
104
- if (!profiles.length) return;
105
- const exact = profiles.find((entry) => entry.width === 1280 && entry.height === 720 && Math.round(entry.frameRate) === 30);
106
- if (exact) return exact;
107
- return [...profiles].sort((a, b) => {
108
- const areaDelta = a.width * a.height - b.width * b.height;
109
- if (areaDelta !== 0) return areaDelta;
110
- return a.frameRate - b.frameRate;
111
- })[0];
112
- };
113
- /** Classify a native failure without depending on projected error shapes.
114
- * HRESULTs surface as `number`/`hresult`; MediaCapture failures also carry a
115
- * numeric `code`. */
116
- var captureFailureKind = (cause) => {
117
- const code = Number(cause?.number ?? cause?.hresult ?? cause?.code ?? NaN);
118
- const message = String(cause?.message ?? cause ?? "").toLowerCase();
119
- if (code === -2147024891 || /access is denied|unauthorizedaccess|e_accessdenied/.test(message)) return "permissionDenied";
120
- if (code === -2147024814 || /sharing violation|in use|0x800700aa/.test(message)) return "unavailable";
121
- if (/no capture devices|device (is )?not (available|found|ready)|cannot find/.test(message)) return "unavailable";
122
- if (/quota|disk|storage|no space|insufficient/.test(message)) return "insufficientStorage";
123
- return "captureFailed";
124
- };
125
- /** WinRT TimeSpan marshals as `{ Duration: bigint|number }` of 100-ns ticks;
126
- * some projections surface the raw tick count. */
127
- var timeSpanToMs = (value) => {
128
- const ticks = typeof value === "bigint" ? Number(value) : typeof value === "number" ? value : Number(value?.Duration ?? value?.duration ?? NaN);
129
- return Number.isFinite(ticks) ? ticks / 1e4 : NaN;
130
- };
131
- var toPromise = async (op) => {
132
- const { NSWinRT } = projection();
133
- if (NSWinRT?.toPromise && op != null) return NSWinRT.toPromise(op);
134
- if (op?.done) return new Promise((resolve, reject) => {
135
- try {
136
- op.done(resolve, reject);
137
- } catch (cause) {
138
- reject(cause);
139
- }
140
- });
141
- return op;
142
- };
143
- var vectorToArray = (items) => {
144
- const result = [];
145
- const size = Number(items?.Size ?? items?.Count ?? 0);
146
- for (let index = 0; index < size; index += 1) result.push(items.GetAt(index));
147
- return result;
148
- };
149
- /** The first backend that initializes a device claims it — a new owner
150
- * cannot seize the same hardware while the previous one is finalizing. */
151
- var claimedDevices = /* @__PURE__ */ new Map();
152
- var normalizePath = (value) => {
153
- let next = value.replace(/\//g, "\\");
154
- next = next.replace(/^\\(?=[A-Za-z]:)/, "");
155
- return next;
156
- };
157
- var fileUrlForPath = (value) => `file:///${value.replace(/\\/g, "/").replace(/^\/+/, "")}`;
158
- function createSessionBackend(config) {
159
- let currentConfig = { ...config };
160
- let observer;
161
- let host;
162
- let listener;
163
- let capture;
164
- let captureElement;
165
- let captureDevice;
166
- let captureAudioMode = false;
167
- let previewReadyFlag = false;
168
- let interrupted = false;
169
- let released = false;
170
- let generation = 0;
171
- let watcher;
172
- let watcherDelegates = [];
173
- let captureDelegates = [];
174
- let sink;
175
- let request;
176
- let recordFile;
177
- let recordStartedAt = 0;
178
- let wantStop = false;
179
- let finalizing = false;
180
- const lease = {};
181
- const projectionAvailable = () => {
182
- const { Windows, Microsoft } = projection();
183
- return !!(Windows?.Media?.Capture?.MediaCapture && Windows?.Media?.Capture?.MediaCaptureInitializationSettings && Windows?.Devices?.Enumeration?.DeviceInformation && Windows?.Storage?.ApplicationData && Microsoft?.UI?.Xaml?.Controls?.CaptureElement);
184
- };
185
- const markReady = (value) => {
186
- if (previewReadyFlag === value) return;
187
- previewReadyFlag = value;
188
- observer?.previewReady(value);
189
- };
190
- const appCapability = (kind) => {
191
- try {
192
- return projection().Windows?.Security?.Authorization?.AppCapabilityAccess?.AppCapability?.Create(capabilityNameFor(kind));
193
- } catch {
194
- return;
195
- }
196
- };
197
- const deviceAccessInfo = (kind) => {
198
- try {
199
- const enumeration = projection().Windows?.Devices?.Enumeration;
200
- const deviceClass = kind === "camera" ? enumeration?.DeviceClass?.VideoCapture : enumeration?.DeviceClass?.AudioCapture;
201
- return deviceClass === void 0 ? void 0 : enumeration?.DeviceAccessInformation?.CreateFromDeviceClass(deviceClass);
202
- } catch {
203
- return;
204
- }
205
- };
206
- const checkPermission = async (kind) => {
207
- const capability = appCapability(kind);
208
- if (capability) try {
209
- const mapped = accessStatusToPermission(capability.CheckAccess());
210
- return mapped === "notDeclared" ? "unknown" : mapped;
211
- } catch {}
212
- const info = deviceAccessInfo(kind);
213
- if (info) try {
214
- return deviceAccessStatusToPermission(info.CurrentStatus);
215
- } catch {
216
- return "unknown";
217
- }
218
- return "unknown";
219
- };
220
- let permissionChain = Promise.resolve();
221
- const requestPermission = (kind) => {
222
- const result = permissionChain.then(async () => {
223
- const status = await checkPermission(kind);
224
- if (status !== "notDetermined") {
225
- if (status === "unknown") try {
226
- if (accessStatusToPermission(appCapability(kind)?.CheckAccess()) === "notDeclared") throw error("configurationMissing", "requestPermission", `Missing '${capabilityNameFor(kind)}' device capability in the host app's Package.appxmanifest`, { permission: kind });
227
- } catch (cause) {
228
- if (cause instanceof CameraCaptureError) throw cause;
229
- }
230
- return status;
231
- }
232
- const capability = appCapability(kind);
233
- if (!capability?.RequestAccessAsync) return "unknown";
234
- try {
235
- const next = accessStatusToPermission(await toPromise(capability.RequestAccessAsync()));
236
- return next === "notDeclared" ? "unknown" : next;
237
- } catch (cause) {
238
- throw error("unavailable", "requestPermission", `The system ${kind} permission request could not run`, cause);
239
- }
240
- });
241
- permissionChain = result.catch(() => {});
242
- return result;
243
- };
244
- const listDevices = async () => {
245
- const enumeration = projection().Windows?.Devices?.Enumeration;
246
- const deviceClass = enumeration?.DeviceClass?.VideoCapture;
247
- if (!enumeration?.DeviceInformation?.FindAllAsync || deviceClass === void 0) return [];
248
- return vectorToArray(await toPromise(enumeration.DeviceInformation.FindAllAsync(deviceClass))).map((device) => ({
249
- id: String(device?.Id ?? device?.id ?? ""),
250
- label: String(device?.Name ?? device?.name ?? device?.Id ?? ""),
251
- facing: panelToFacing(Number(device?.EnclosureLocation?.Panel ?? device?.EnclosureLocation?.panel))
252
- }));
253
- };
254
- const listAudioDevices = async () => {
255
- try {
256
- const enumeration = projection().Windows?.Devices?.Enumeration;
257
- const deviceClass = enumeration?.DeviceClass?.AudioCapture;
258
- if (!enumeration?.DeviceInformation?.FindAllAsync || deviceClass === void 0) return 0;
259
- const found = await toPromise(enumeration.DeviceInformation.FindAllAsync(deviceClass));
260
- return Number(found?.Size ?? found?.Count ?? 0);
261
- } catch {
262
- return 0;
263
- }
264
- };
265
- const defaultDeviceId = () => {
266
- try {
267
- const devices = projection().Windows?.Media?.Devices;
268
- const kind = devices?.AudioVideoCaptureDeviceKind?.Video;
269
- const id = devices?.MediaDevice?.GetDefaultVideoCaptureId?.(kind);
270
- return typeof id === "string" && id ? id : void 0;
271
- } catch {
272
- return;
273
- }
274
- };
275
- const streamProfiles = (target, streamKind) => {
276
- const controller = target?.VideoDeviceController;
277
- if (!controller?.GetAvailableMediaStreamProperties || streamKind === void 0) return [];
278
- try {
279
- return vectorToArray(controller.GetAvailableMediaStreamProperties(streamKind)).map((entry) => ({
280
- width: Number(entry?.Width ?? 0),
281
- height: Number(entry?.Height ?? 0),
282
- frameRate: Number(entry?.FrameRate?.Numerator ?? 0) / Math.max(1, Number(entry?.FrameRate?.Denominator ?? 1)),
283
- subtype: typeof entry?.Subtype === "string" ? entry.Subtype : void 0
284
- })).filter((entry) => entry.width > 0 && entry.height > 0);
285
- } catch {
286
- return [];
287
- }
288
- };
289
- const recordStreamProfiles = () => streamProfiles(capture, projection().Windows?.Media?.Capture?.MediaStreamType?.VideoRecord);
290
- const detachCaptureDelegates = () => {
291
- for (const entry of captureDelegates) try {
292
- capture[entry.event] = null;
293
- } catch {}
294
- captureDelegates = [];
295
- };
296
- const onCaptureFailed = (_sender, args) => {
297
- const message = String(args?.Message ?? args?.message ?? "Camera capture failed");
298
- markReady(false);
299
- observer?.availability(false);
300
- observer?.interruption("began", /in use|denied|access/i.test(message) ? "cameraInUse" : "unknown");
301
- if (sink) {
302
- sink.finishing(/device|available|found/i.test(message) ? "cameraUnavailable" : "captureError");
303
- stopRecording();
304
- }
305
- };
306
- const onRecordLimitationExceeded = () => {
307
- sink?.finishing("interrupted");
308
- stopRecording();
309
- };
310
- const attachCaptureDelegates = (target) => {
311
- const { NSWinRT } = projection();
312
- const subscribe = (event, type, handler) => {
313
- try {
314
- const delegate = NSWinRT?.asDelegate ? NSWinRT.asDelegate(type, handler) : handler;
315
- target[event] = delegate;
316
- captureDelegates.push({
317
- event,
318
- delegate
319
- });
320
- } catch {
321
- try {
322
- target[event] = handler;
323
- captureDelegates.push({
324
- event,
325
- delegate: handler
326
- });
327
- } catch {}
328
- }
329
- };
330
- subscribe("Failed", "Windows.Foundation.TypedEventHandler`2<Windows.Media.Capture.MediaCapture,Windows.Media.Capture.MediaCaptureFailedEventArgs>", onCaptureFailed);
331
- subscribe("RecordLimitationExceeded", "Windows.Media.Capture.RecordLimitationExceededEventHandler", onRecordLimitationExceeded);
332
- };
333
- const stopWatcher = () => {
334
- if (!watcher) return;
335
- for (const entry of watcherDelegates) try {
336
- watcher[entry.event] = null;
337
- } catch {}
338
- watcherDelegates = [];
339
- try {
340
- watcher.Stop();
341
- } catch {}
342
- watcher = void 0;
343
- };
344
- const ensureWatcher = () => {
345
- if (watcher) return;
346
- const enumeration = projection().Windows?.Devices?.Enumeration;
347
- const deviceClass = enumeration?.DeviceClass?.VideoCapture;
348
- if (!enumeration?.DeviceInformation?.CreateWatcher || deviceClass === void 0) return;
349
- try {
350
- const target = enumeration.DeviceInformation.CreateWatcher(deviceClass);
351
- const { NSWinRT } = projection();
352
- const subscribe = (event, type, handler) => {
353
- const delegate = NSWinRT?.asDelegate ? NSWinRT.asDelegate(type, handler) : handler;
354
- target[event] = delegate;
355
- watcherDelegates.push({
356
- event,
357
- delegate
358
- });
359
- };
360
- subscribe("Removed", "Windows.Foundation.TypedEventHandler`2<Windows.Devices.Enumeration.DeviceWatcher,Windows.Devices.Enumeration.DeviceInformationUpdate>", (_sender, update) => {
361
- const id = String(update?.Id ?? update?.id ?? "");
362
- if (!id || id === captureDevice?.id) observer?.availability(false);
363
- });
364
- subscribe("Added", "Windows.Foundation.TypedEventHandler`2<Windows.Devices.Enumeration.DeviceWatcher,Windows.Devices.Enumeration.DeviceInformation>", () => observer?.availability(true));
365
- target.Start();
366
- watcher = target;
367
- } catch {
368
- watcher = void 0;
369
- watcherDelegates = [];
370
- }
371
- };
372
- const applyStreamProperties = async (target) => {
373
- const streamType = projection().Windows?.Media?.Capture?.MediaStreamType;
374
- const controller = target?.VideoDeviceController;
375
- const candidates = streamProfiles(target, streamType?.VideoRecord);
376
- const chosen = chooseStandardProfile(candidates);
377
- if (!controller?.SetMediaStreamPropertiesAsync || !chosen || !streamType) return;
378
- const props = candidates.length ? vectorToArray(controller.GetAvailableMediaStreamProperties(streamType.VideoRecord)).find((entry) => Number(entry?.Width) === chosen.width && Number(entry?.Height) === chosen.height) : void 0;
379
- if (!props) return;
380
- try {
381
- await toPromise(controller.SetMediaStreamPropertiesAsync(streamType.VideoRecord, props));
382
- try {
383
- await toPromise(controller.SetMediaStreamPropertiesAsync(streamType.VideoPreview, props));
384
- } catch {}
385
- } catch {}
386
- };
387
- const releaseClaim = () => {
388
- if (captureDevice && claimedDevices.get(captureDevice.id) === lease) claimedDevices.delete(captureDevice.id);
389
- };
390
- const closeCapture = () => {
391
- detachCaptureDelegates();
392
- releaseClaim();
393
- const target = capture;
394
- capture = void 0;
395
- captureDevice = void 0;
396
- captureAudioMode = false;
397
- if (!target) return;
398
- try {
399
- target.Close();
400
- } catch {
401
- try {
402
- target.Dispose();
403
- } catch {}
404
- }
405
- };
406
- const ensureCapture = async () => {
407
- const epoch = generation;
408
- if (capture) return capture;
409
- const { Windows } = projection();
410
- const devices = await listDevices();
411
- const selected = selectCameraDevice(currentConfig.camera, devices, defaultDeviceId());
412
- if (!selected) throw error("unavailable", "startPreview", "No camera device is available");
413
- const owner = claimedDevices.get(selected.id);
414
- if (owner && owner !== lease) throw error("busy", "startPreview", "Another camera session still owns this device");
415
- const settings = new Windows.Media.Capture.MediaCaptureInitializationSettings();
416
- settings.VideoDeviceId = selected.id;
417
- settings.StreamingCaptureMode = currentConfig.audio === true ? captureModeAudioAndVideo : captureModeVideo;
418
- const target = new Windows.Media.Capture.MediaCapture();
419
- try {
420
- await toPromise(target.InitializeAsync(settings));
421
- } catch (cause) {
422
- try {
423
- target.Close();
424
- } catch {}
425
- throw error(captureFailureKind(cause), "startPreview", "The camera could not be initialized", cause);
426
- }
427
- if (epoch !== generation || released) {
428
- try {
429
- target.Close();
430
- } catch {}
431
- throw error("unavailable", "startPreview", "The camera session was released");
432
- }
433
- capture = target;
434
- captureDevice = selected;
435
- captureAudioMode = currentConfig.audio === true;
436
- claimedDevices.set(selected.id, lease);
437
- attachCaptureDelegates(target);
438
- ensureWatcher();
439
- await applyStreamProperties(target);
440
- try {
441
- if (selected.facing === "front" && typeof target.SetPreviewMirroring === "function") target.SetPreviewMirroring(true);
442
- } catch {}
443
- return target;
444
- };
445
- const activate = async () => {
446
- const epoch = ++generation;
447
- try {
448
- const target = await ensureCapture();
449
- if (!captureElement || epoch !== generation) return;
450
- try {
451
- captureElement.Source = target;
452
- } catch (cause) {
453
- throw error("unavailable", "startPreview", "The preview element rejected the source", cause);
454
- }
455
- await toPromise(target.StartPreviewAsync());
456
- if (epoch !== generation) return;
457
- markReady(true);
458
- listener?.onReady?.();
459
- } catch (cause) {
460
- if (epoch === generation) {
461
- markReady(false);
462
- listener?.onError?.(cause instanceof CameraCaptureError ? cause : error("unavailable", "startPreview", "Camera preview could not start", cause));
463
- }
464
- }
465
- };
466
- const deactivate = async () => {
467
- generation += 1;
468
- markReady(false);
469
- const target = capture;
470
- if (captureElement) try {
471
- captureElement.Source = null;
472
- } catch {}
473
- if (target && !sink) try {
474
- await toPromise(target.StopPreviewAsync());
475
- } catch {}
476
- };
477
- const recordingsFolder = async () => {
478
- const folder = (projection().Windows?.Storage)?.ApplicationData?.Current?.LocalFolder;
479
- if (!folder?.CreateFolderAsync) throw error("destinationUnavailable", "startRecording", "App-private storage is unavailable");
480
- return toPromise(folder.CreateFolderAsync("octane-camera", collisionOpenIfExists));
481
- };
482
- const reserveDestination = async (destinationPath) => {
483
- const storage = projection().Windows?.Storage;
484
- if (!destinationPath) {
485
- const folder = await recordingsFolder();
486
- const name = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`;
487
- return toPromise(folder.CreateFileAsync(name, collisionGenerateUniqueName));
488
- }
489
- const filePath = normalizePath(destinationPath);
490
- const directory = filePath.slice(0, filePath.lastIndexOf("\\"));
491
- const name = filePath.slice(filePath.lastIndexOf("\\") + 1);
492
- if (!directory || !name) throw error("destinationUnavailable", "startRecording", "destinationFileUrl is not a file path");
493
- const tempPath = String(storage?.ApplicationData?.Current?.TemporaryFolder?.Path ?? "");
494
- if (tempPath && filePath.toLowerCase().startsWith(tempPath.toLowerCase())) throw error("destinationUnavailable", "startRecording", "Temporary storage does not satisfy durable output");
495
- return toPromise((await toPromise(storage.StorageFolder.GetFolderFromPathAsync(directory)).catch((cause) => {
496
- throw error("destinationUnavailable", "startRecording", "destinationFileUrl directory is not writable", cause);
497
- })).CreateFileAsync(name, collisionFailIfExists)).catch((cause) => {
498
- throw error("destinationUnavailable", "startRecording", "destinationFileUrl already exists or cannot be created", cause);
499
- });
500
- };
501
- const freeDiskBytes = async () => {
502
- try {
503
- const folder = projection().Windows?.Storage?.ApplicationData?.Current?.LocalFolder;
504
- const result = await toPromise(folder.Properties.RetrievePropertiesAsync(["System.FreeSpace"]));
505
- const value = result?.["System.FreeSpace"] ?? result?.Lookup?.("System.FreeSpace");
506
- return Number.isFinite(Number(value)) ? Number(value) : Number.MAX_SAFE_INTEGER;
507
- } catch {
508
- return Number.MAX_SAFE_INTEGER;
509
- }
510
- };
511
- const removeFileQuietly = async (file) => {
512
- if (!file) return;
513
- try {
514
- await toPromise(file.DeleteAsync());
515
- } catch {}
516
- };
517
- const nativeVideoMetadata = async (file, audioRequested) => {
518
- const { Windows } = projection();
519
- const basic = await toPromise(file.GetBasicPropertiesAsync());
520
- if (!(Number(basic?.Size ?? 0) > 0)) throw error("noMedia", "finalize", "The recorder produced an empty movie file");
521
- const video = await toPromise(file.Properties.GetVideoPropertiesAsync());
522
- const width = Number(video?.Width ?? 0);
523
- const height = Number(video?.Height ?? 0);
524
- let durationMs = timeSpanToMs(video?.Duration);
525
- let hasAudio = false;
526
- let audioKnown = true;
527
- try {
528
- const source = Windows.Media.Core.MediaSource.CreateFromStorageFile(file);
529
- const audioTracks = new Windows.Media.Playback.MediaPlaybackItem(source)?.AudioTracks;
530
- hasAudio = Number(audioTracks?.Size ?? audioTracks?.Count ?? 0) > 0;
531
- if (!Number.isFinite(durationMs) || durationMs <= 0) durationMs = timeSpanToMs(source?.Duration);
532
- } catch {
533
- audioKnown = false;
534
- }
535
- if (audioRequested && !audioKnown) throw error("finalizationFailed", "finalize", "The audio track could not be verified in the finalized movie");
536
- return {
537
- durationMs,
538
- width,
539
- height,
540
- hasAudio: audioKnown && hasAudio,
541
- orientation: videoOrientationToOrientation(Number(video?.Orientation))
542
- };
543
- };
544
- const mediabunnyMetadata = async (file) => {
545
- const { Windows } = projection();
546
- const buffer = await toPromise(Windows.Storage.FileIO.ReadBufferAsync(file));
547
- const length = Number(buffer?.Length ?? 0);
548
- if (!(length > 0)) throw error("noMedia", "finalize", "The recorder produced an empty movie file");
549
- const reader = Windows.Storage.Streams.DataReader.FromBuffer(buffer);
550
- const bytes = new Uint8Array(length);
551
- reader.ReadBytes(bytes);
552
- const { BufferSource, BlobSource, Input, MP4 } = await import("mediabunny");
553
- const input = new Input({
554
- source: new (BufferSource ?? BlobSource)(bytes),
555
- formats: [MP4]
556
- });
557
- try {
558
- const video = await input.getPrimaryVideoTrack();
559
- if (!video) throw error("noMedia", "finalize", "The movie contains no video track");
560
- const audio = await input.getPrimaryAudioTrack();
561
- const hasAudio = audio !== null && await audio.computeDuration() > 0;
562
- return {
563
- durationMs: await input.computeDuration() * 1e3,
564
- width: await video.getDisplayWidth(),
565
- height: await video.getDisplayHeight(),
566
- hasAudio,
567
- orientation: "unspecified"
568
- };
569
- } finally {
570
- input.dispose();
571
- }
572
- };
573
- const teardown = () => {
574
- if (!host) deactivate().then(() => {
575
- if (!host && !sink) closeCapture();
576
- });
577
- };
578
- const finishWithError = (stage, cause) => {
579
- const target = sink;
580
- sink = void 0;
581
- request = void 0;
582
- finalizing = false;
583
- wantStop = false;
584
- recordStartedAt = 0;
585
- const file = recordFile;
586
- recordFile = void 0;
587
- removeFileQuietly(file);
588
- target?.settled({
589
- kind: "failed",
590
- stage,
591
- issue: cause
592
- });
593
- teardown();
594
- };
595
- const settleClip = async (file) => {
596
- const target = sink;
597
- const take = request;
598
- if (!target || !take) return;
599
- try {
600
- let metadata;
601
- try {
602
- metadata = await nativeVideoMetadata(file, take.audio);
603
- } catch (nativeCause) {
604
- if (nativeCause instanceof CameraCaptureError && nativeCause.kind === "noMedia") throw nativeCause;
605
- metadata = await mediabunnyMetadata(file);
606
- }
607
- if (!(metadata.durationMs > 0) || !(metadata.width > 0) || !(metadata.height > 0)) throw error("noMedia", "finalize", "The finalized movie has no usable video media");
608
- if (take.audio && !metadata.hasAudio) throw error("captureFailed", "finalize", "Requested audio is absent from the finalized movie");
609
- const filePath = String(file?.Path ?? "");
610
- sink = void 0;
611
- request = void 0;
612
- recordFile = void 0;
613
- finalizing = false;
614
- wantStop = false;
615
- target.settled({
616
- kind: "clip",
617
- output: {
618
- kind: "nativeFile",
619
- resourceId: `file:${filePath}`,
620
- fileUrl: fileUrlForPath(filePath)
621
- },
622
- mimeType: "video/mp4",
623
- durationMs: Math.round(metadata.durationMs),
624
- width: metadata.width,
625
- height: metadata.height,
626
- orientation: take.orientation && take.orientation !== "unspecified" ? take.orientation : metadata.orientation,
627
- hasAudio: take.audio && metadata.hasAudio
628
- });
629
- } catch (cause) {
630
- sink = void 0;
631
- request = void 0;
632
- recordFile = void 0;
633
- finalizing = false;
634
- wantStop = false;
635
- removeFileQuietly(file);
636
- target.settled({
637
- kind: "failed",
638
- stage: "finalization",
639
- issue: cause instanceof CameraCaptureError ? cause : issue("finalizationFailed", "finalize", cause instanceof Error ? cause.message : String(cause), { cause })
640
- });
641
- }
642
- recordStartedAt = 0;
643
- teardown();
644
- };
645
- const stopRecording = async () => {
646
- const target = capture;
647
- const file = recordFile;
648
- if (!target || !file || finalizing || recordStartedAt === 0) return;
649
- finalizing = true;
650
- try {
651
- await toPromise(target.StopRecordAsync());
652
- } catch (cause) {
653
- finishWithError("finalization", error("finalizationFailed", "finalize", "The movie could not be finalized", cause));
654
- return;
655
- }
656
- await settleClip(file);
657
- };
658
- const onSuspend = () => {
659
- interrupted = true;
660
- markReady(false);
661
- observer?.interruption("began", "background");
662
- };
663
- const onResume = () => {
664
- interrupted = false;
665
- observer?.interruption("ended", "background");
666
- if (capture) {
667
- observer?.availability(true);
668
- markReady(!!captureElement);
669
- }
670
- };
671
- let lifecycleBound = false;
672
- const bindLifecycle = () => {
673
- if (lifecycleBound) return;
674
- lifecycleBound = true;
675
- try {
676
- Application.on(Application.suspendEvent, onSuspend);
677
- Application.on(Application.resumeEvent, onResume);
678
- } catch {}
679
- };
680
- const unbindLifecycle = () => {
681
- if (!lifecycleBound) return;
682
- lifecycleBound = false;
683
- try {
684
- Application.off(Application.suspendEvent, onSuspend);
685
- Application.off(Application.resumeEvent, onResume);
686
- } catch {}
687
- };
688
- return {
689
- platform: "windows",
690
- get supported() {
691
- return projectionAvailable();
692
- },
693
- checkPermission,
694
- requestPermission: async (kind) => {
695
- const status = await requestPermission(kind);
696
- if (kind === "camera" && status === "granted" && host && !sink && !released && !capture) try {
697
- await activate();
698
- } catch {}
699
- return status;
700
- },
701
- getCapabilities: async () => {
702
- const cameras = (await listDevices()).map((entry) => ({
703
- id: entry.id,
704
- label: entry.label,
705
- facing: entry.facing
706
- }));
707
- const supported = projectionAvailable() && cameras.length > 0;
708
- const cameraStatus = await checkPermission("camera");
709
- const available = supported && cameraStatus === "granted";
710
- const profile = (capture ? chooseStandardProfile(recordStreamProfiles()) : void 0) ?? {
711
- width: 1280,
712
- height: 720,
713
- frameRate: 30
714
- };
715
- return {
716
- supported,
717
- reason: !projectionAvailable() ? "The WinUI camera runtime is unavailable" : cameras.length === 0 ? "No camera device is available on this host" : cameraStatus !== "granted" ? "Camera permission has not been granted" : void 0,
718
- available,
719
- cameras,
720
- profiles: [{
721
- profile: "standard",
722
- ...profile
723
- }],
724
- audio: await listAudioDevices() > 0,
725
- orientations: capture && typeof capture.SetRecordRotationAsync === "function" ? [
726
- "portrait",
727
- "portraitUpsideDown",
728
- "landscapeLeft",
729
- "landscapeRight"
730
- ] : [],
731
- durationLimit: "bestEffort",
732
- elapsedTime: "estimated",
733
- output: {
734
- mimeType: "video/mp4",
735
- container: "mp4",
736
- storage: "appPrivateFile",
737
- destinationFileUrl: true
738
- }
739
- };
740
- },
741
- previewReady: () => previewReadyFlag && !interrupted,
742
- validateStart: (options) => {
743
- if (options.orientation && options.orientation !== "unspecified" && (!capture || typeof capture.SetRecordRotationAsync !== "function")) throw error("unsupportedConfiguration", "startRecording", "Fixed cardinal orientation has not been qualified on this Windows source");
744
- },
745
- attachPreview: (target, callbacks) => {
746
- if (released || sink) throw error("invalidState", "attachPreview", "The camera is disposed or finalizing");
747
- host = target;
748
- listener = callbacks;
749
- const view = target;
750
- captureElement = view?.nativeViewProtected ?? view;
751
- bindLifecycle();
752
- checkPermission("camera").then((status) => {
753
- if (released || !host) return;
754
- if (status !== "granted") {
755
- listener?.onError?.(error(status === "restricted" ? "permissionRestricted" : "permissionDenied", "preview", "Request camera permission before activating this preview", { permission: "camera" }));
756
- return;
757
- }
758
- activate();
759
- });
760
- return () => {
761
- host = void 0;
762
- listener = void 0;
763
- captureElement = void 0;
764
- deactivate().then(() => {
765
- if (!sink) closeCapture();
766
- });
767
- };
768
- },
769
- configure: (next) => {
770
- const changed = JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) || (next.audio ?? false) !== (currentConfig.audio ?? false);
771
- currentConfig = { ...next };
772
- if (!changed || !capture) return;
773
- const hadPreview = !!captureElement;
774
- deactivate().then(() => {
775
- closeCapture();
776
- if (hadPreview && host) return activate();
777
- }).catch(() => {
778
- observer?.availability(false);
779
- });
780
- },
781
- startCapture: (take, target) => {
782
- if (!capture || !captureDevice || !previewReadyFlag || interrupted) {
783
- target.settled({
784
- kind: "failed",
785
- stage: "preparation",
786
- issue: issue("unavailable", "startRecording", "The camera preview is not running")
787
- });
788
- return;
789
- }
790
- sink = target;
791
- request = take;
792
- const active = capture;
793
- const run = async () => {
794
- if (take.audio) {
795
- const micStatus = await checkPermission("microphone");
796
- if (micStatus !== "granted") throw error(micStatus === "restricted" ? "permissionRestricted" : micStatus === "unavailable" ? "unavailable" : "permissionDenied", "startRecording", `Microphone permission is ${micStatus}`, { permission: "microphone" });
797
- if (!captureAudioMode) throw error("unsupportedConfiguration", "startRecording", "This session was created without audio; configure audio before recording");
798
- }
799
- if (await freeDiskBytes() < minFreeDiskBytes) throw error("insufficientStorage", "startRecording", "Not enough local storage for a movie recording");
800
- if (take.orientation) {
801
- const rotation = orientationToRotation(take.orientation);
802
- if (rotation === void 0 || typeof active.SetRecordRotationAsync !== "function") throw error("unsupportedConfiguration", "startRecording", `Orientation '${take.orientation}' is not supported by this camera`);
803
- try {
804
- await toPromise(active.SetRecordRotationAsync(rotation));
805
- } catch (cause) {
806
- throw error("unsupportedConfiguration", "startRecording", `Orientation '${take.orientation}' was rejected by this camera`, cause);
807
- }
808
- }
809
- const file = await reserveDestination(take.destinationPath);
810
- const media = projection().Windows?.Media;
811
- const profile = media?.MediaProperties?.MediaEncodingProfile?.CreateMp4(media?.MediaProperties?.VideoEncodingQuality?.Auto ?? 0);
812
- if (!profile) {
813
- await removeFileQuietly(file);
814
- throw error("unsupportedPlatform", "startRecording", "MP4 encoding profiles are unavailable on this host");
815
- }
816
- const chosen = chooseStandardProfile(recordStreamProfiles());
817
- try {
818
- if (chosen && profile.Video) {
819
- profile.Video.Width = chosen.width;
820
- profile.Video.Height = chosen.height;
821
- if (profile.Video.FrameRate) {
822
- profile.Video.FrameRate.Numerator = Math.max(1, Math.round(chosen.frameRate * 1e3));
823
- profile.Video.FrameRate.Denominator = 1e3;
824
- }
825
- }
826
- } catch {}
827
- if (!take.audio) try {
828
- profile.Audio = null;
829
- } catch {}
830
- recordFile = file;
831
- wantStop = false;
832
- try {
833
- await toPromise(active.StartRecordToStorageFileAsync(profile, file));
834
- } catch (cause) {
835
- recordFile = void 0;
836
- await removeFileQuietly(file);
837
- throw error(captureFailureKind(cause), "startRecording", "MediaCapture could not begin recording", cause);
838
- }
839
- recordStartedAt = Date.now();
840
- const stream = chooseStandardProfile(recordStreamProfiles());
841
- const configuration = {
842
- cameraId: captureDevice.id,
843
- facing: captureDevice.facing,
844
- audio: take.audio && captureAudioMode,
845
- width: stream?.width ?? chosen?.width ?? 0,
846
- height: stream?.height ?? chosen?.height ?? 0,
847
- frameRate: stream?.frameRate ?? chosen?.frameRate ?? 0,
848
- orientation: take.orientation ?? "unspecified",
849
- mimeType: "video/mp4",
850
- container: "mp4"
851
- };
852
- target.started(configuration);
853
- if (wantStop) stopRecording();
854
- };
855
- run().catch((cause) => {
856
- finishWithError("preparation", cause instanceof CameraCaptureError ? cause : issue("captureFailed", "startRecording", cause instanceof Error ? cause.message : String(cause), { cause }));
857
- });
858
- },
859
- stopCapture: () => {
860
- wantStop = true;
861
- stopRecording();
862
- },
863
- elapsedMs: () => recordStartedAt > 0 ? Math.max(0, Date.now() - recordStartedAt) : 0,
864
- openOutput: async (output) => {
865
- if (output.kind !== "nativeFile" || output.synthetic) throw error("invalidArgument", "openOutput", "Expected a native Windows movie reference");
866
- const filePath = normalizePath(output.fileUrl.slice(7));
867
- const storage = projection().Windows?.Storage;
868
- try {
869
- await toPromise(storage.StorageFile.GetFileFromPathAsync(filePath));
870
- } catch (cause) {
871
- throw error("unavailable", "openOutput", `Movie file is missing at ${output.fileUrl}`, cause);
872
- }
873
- return {
874
- url: output.fileUrl,
875
- fileUrl: output.fileUrl,
876
- release: () => {}
877
- };
878
- },
879
- release: async () => {
880
- released = true;
881
- generation += 1;
882
- unbindLifecycle();
883
- sink = void 0;
884
- request = void 0;
885
- wantStop = false;
886
- finalizing = false;
887
- recordStartedAt = 0;
888
- const file = recordFile;
889
- recordFile = void 0;
890
- removeFileQuietly(file);
891
- markReady(false);
892
- await deactivate();
893
- stopWatcher();
894
- closeCapture();
895
- host = void 0;
896
- captureElement = void 0;
897
- },
898
- onPreviewDetached: () => {},
899
- setObserver: (next) => {
900
- observer = next;
901
- }
902
- };
903
- }
904
- //#endregion
905
- export { accessStatusToPermission, captureFailureKind, chooseStandardProfile, createSessionBackend, deviceAccessStatusToPermission, orientationToRotation, panelToFacing, selectCameraDevice, videoOrientationToOrientation };